NousResearch/hermes-agent
Kanban task file attachment upload feature and fixes
Cluster 35338 · dashboard. Data as of
1 open item. Fix merged in #35395 on 30 May 2026. Check it covers your case.
The short answer
0open issues
1open PR
3items grouped
- Known?
- Yes. 3 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #35395. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PR #35395. That is separate from the fix event. - Still open
- 0 issues and 1 PR.
These members cover adding file attachment support to Kanban tasks and handling cleanup of partial uploads when an OS error occurs during the upload process.
Open issues0
Open PRs1
- PR#35596fix(kanban): delete partial attachment file on OSError during upload
Fixes on record
-
#35395 feat(kanban): file attachments on tasks (#35338)
files:
hermes_cli/kanban_db.py,plugins/kanban/dashboard/dist/index.js,plugins/kanban/dashboard/dist/style.css,plugins/kanban/dashboard/plugin_api.py,tests/plugins/test_kanban_attachments.py,website/docs/user-guide/features/kanban.mdShow the diff
-
hermes_cli/kanban_db.py@@ -396,6 +396,41 @@ def workspaces_root(board: Optional[str] = None) -> Path: return board_dir(slug) / "workspaces" +def attachments_root(board: Optional[str] = None) -> Path: + """Return the directory under which task file attachments are stored. + + Mirrors :func:`worker_logs_dir` / :func:`workspaces_root`: anchored + per-board so attachments don't leak between projects. Each task gets + its own ``<root>/.../attachments/<task_id>/`` subdirectory. + + ``HERMES_KANBAN_ATTACHMENTS_ROOT`` pins the path directly (highest + precedence) for tests and unusual deployments. + + ``default`` uses ``<root>/kanban/attachments/``; other boards use + ``<root>/kanban/boards/<slug>/attachments/``. + + Workers (which run with full file-tool access) read attached files + by the absolute path surfaced in :func:`build_worker_context`. On the + local terminal backend — the default for kanban — that path resolves + directly. Remote backends (Docker/Modal) need this directory mounted; + see the kanban docs. + """ + override = os.environ.get("HERMES_KANBAN_ATTACHMENTS_ROOT", "").strip() + if override: + return Path(override).expanduser() + slug = _normalize_board_slug(board) + if slug is None: + slug = get_current_board() + if slug == DEFAULT_BOARD: + return kanban_home() / "kanban" / "attachments" + return board_dir(slug) / "attachments" + + +def task_attachments_dir(task_id: str, board: Optional[str] = None) -> Path: + """Return the per-task attachment directory ``<root>/<task_id>/``.""" + return attachments_root(board=board) / task_id + + def worker_logs_dir(board: Optional[str] = None) -> Path: """Return the directory under which per-task worker logs are written. @@ -831,6 +866,20 @@ class Comment: created_at: int +@dataclass +class Attachment: + """In-memory view of a row from the ``task_attachments`` table.""" + + id: int + task_id: str + filename: str + stored_path: str + content_type: Optional[str] + size: int + uploaded_by: Optional[str] + created_at: int + + @dataclass class Event: id: int @@ -957,6 +1006,23 @@ class Event: error TEXT ); +-- Files attached to a task (PDFs, images, source documents). The blob +-- lives on disk under ``attachments_root(board)/<task_id>/<stored_name>``; +-- this row carries metadata + the absolute ``stored_path`` so the +-- dashboard can list/download and ``build_worker_context`` can surface +-- the absolute path to the worker (which has full file-tool access). See +-- #35338. +CREATE TABLE IF NOT EXISTS task_attachments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL, + filename TEXT NOT NULL, + stored_path TEXT NOT NULL, + content_type TEXT, + size INTEGER NOT NULL DEFAULT 0, + uploaded_by TEXT, + created_at INTEGER NOT NULL +); + -- Subscription from a gateway source (platform + chat + thread) to a -- task. The gateway's kanban-notifier watcher tails task_events and -- pushes ``completed`` / ``blocked`` / ``spawn_auto_blocked`` events to @@ -981,6 +1047,7 @@ class Event: CREATE INDEX IF NOT EXISTS idx_events_task ON task_events(task_id, created_at); CREATE INDEX IF NOT EXISTS idx_runs_task ON task_runs(task_id, started_at); CREATE INDEX IF NOT EXISTS idx_runs_status ON task_runs(status); +CREATE INDEX IF NOT EXISTS idx_attachments_task ON task_attachments(task_id, created_at); CREATE INDEX IF NOT EXISTS idx_notify_task ON kanban_notify_subs(task_id); """ @@ -2386,6 +2453,121 @@ def list_comments(conn: sqlite3.Connection, task_id: str) -> list[Comment]: ] +# --------------------------------------------------------------------------- +# Attachments +# --------------------------------------------------------------------------- + +def add_attachment( + conn: sqlite3.Connection, + task_id: str, + *, + filename: str, + stored_path: str, + content_type: Optional[str] = None, + size: int = 0, + uploaded_by: Optional[str] = None, +) -> int: + """Record a file attachment for a task. Returns the new attachment id. + + The caller is responsible for writing the blob to ``stored_path`` + first (under :func:`task_attachments_dir`); this only persists the + metadata row and appends an ``attached`` event. + """ + if not filename or not filename.strip(): + raise ValueError("attachment filename is required") + if not stored_path or not stored_path.strip(): + raise ValueError("attachment stored_path is required") + now = int(time.time()) + with write_txn(conn): + if not conn.execute( + "SELECT 1 FROM tasks WHERE id = ?", (task_id,) + ).fetchone(): + raise ValueError(f"unknown task {task_id}") + cur = conn.execute( + "INSERT INTO task_attachments " + "(task_id, filename, stored_path, content_type, size, uploaded_by, created_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + ( + task_id, + filename.strip(), + stored_path, + content_type, + int(size), + uploaded_by, + now, + ), + ) + _append_event( + conn, + task_id, + "attached", + {"filename": filename.strip(), "size": int(size), "by": uploaded_by}, + ) + return int(cur.lastrowid or 0) + + +def list_attachments(conn: sqlite3.Connection, task_id: str) -> list[Attachment]: + rows = conn.execute( + "SELECT * FROM task_attachments WHERE task_id = ? ORDER BY created_at ASC, id ASC", + (task_id,), + ).fetchall() + return [ + Attachment( + id=r["id"], + task_id=r["task_id"], + filename=r["filename"], + stored_path=r["stored_path"], + content_type=r["content_type"], + size=r["size"] or 0, + uploaded_by=r["uploaded_by"], + created_at=r["created_at"], + ) + for r in rows + ] + + +def get_attachment(conn: sqlite3.Connection, attachment_id: int) -> Optional[Attachment]: + r = conn.execute( + "SELECT * FROM task_attachments WHERE id = ?", (attachment_id,) + ).fetchone() + if r is None: + return None + return Attachment( + id=r["id"], + task_id=r["task_id"], + filename=r["filename"], + stored_path=r["stored_path"], + content_type=r["content_type"], + size=r["size"] or 0, + uploaded_by=r["uploaded_by"], + created_at=r["created_at"], + ) + + +def delete_attachment(conn: sqlite3.Connection, attachment_id: int) -> Optional[Attachment]: + """Delete an attachment row and its on-disk blob. Returns the removed row. + + Returns ``None`` when no row matched. The blob is removed best-effort + (a missing file is not an error); the metadata row is the source of + truth for whether an attachment "exists". + """ + with write_txn(conn): + att = get_attachment(conn, attachment_id) + if att is None: + return None + conn.execute("DELETE FROM task_attachments WHERE id = ?", (attachment_id,)) + _append_event( + conn, att.task_id, "attachment_removed", {"filename": att.filename} + ) + try: + p = Path(att.stored_path) + if p.is_file(): + p.unlink() + except OSError: + pass + return att + + def list_events(conn: sqlite3.Connection, task_id: str) -> list[Event]: rows = conn.execute( "SELECT * FROM task_events WHERE task_id = ? ORDER BY created_at ASC, id ASC", @@ -6465,6 +6647,25 @@ def _cap(s: Optional[str], limit: int = _CTX_MAX_FIELD_BYTES) -> str: lines.append(_cap(task.body, _CTX_MAX_BODY_BYTES)) lines.append("") + # Attachments — files uploaded to this task (PDFs, source docs, + # images). Surface the absolute on-disk path so the worker, which has + # full file-tool access, can read them directly (read_file, terminal + # `pdftotext`, etc.). On the local terminal backend the path resolves + # as-is; remote backends need the kanban attachments dir mounted. + attachments = list_attachments(conn, task_id) + if attachments: + lines.append("## Attachments") + lines.append( + "Files attached to this task. Read them with the file/terminal " + "tools at the absolute paths below:" + ) + for att in attachments: + size_kb = max(1, (att.size + 1023) // 1024) if att.size else 0 + size_str = f", {size_kb} KB" if size_kb else "" + ctype = f", {att.content_type}" if att.content_type else "" + lines.append(f"- `{att.filename}`{ctype}{size_str} → `{att.stored_path}`") + lines.append("") + # Prior attempts — show closed runs so a retrying worker sees the # history. Skip the currently-active run (that's this worker). # Cap at _CTX_MAX_PRIOR_ATTEMPTS most-recent closed runs; older -
plugins/kanban/dashboard/dist/index.js@@ -2741,6 +2741,8 @@ // Ready/Block/Complete buttons feel like no-ops. See #26744. const [patchErr, setPatchErr] = useState(null); const [newComment, setNewComment] = useState(""); + const [uploadBusy, setUploadBusy] = useState(false); + const [uploadErr, setUploadErr] = useState(null); const [editing, setEditing] = useState(false); // Home-channel notification toggles. homeChannels is the list of platforms // the user has a /sethome on; each entry has a `subscribed` bool telling @@ -2789,6 +2791,49 @@ }).catch(function (e) { setErr(String(e.message || e)); }); }; + // File upload uses raw fetch (not SDK.fetchJSON, which JSON-encodes) + // so the browser sets the multipart boundary. Auth rides the session + // cookie + bearer token, matching the rest of the dashboard. + const handleUpload = function (fileList) { + const files = Array.prototype.slice.call(fileList || []); + if (!files.length) return; + setUploadBusy(true); + setUploadErr(null); + const token = [redacted:secret-assignment] || ""; + const headers = token ? { Authorization: "Bearer " + token } : {}; + const url = withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}/attachments`, boardSlug); + // Upload sequentially so a partial failure leaves a clear state. + let chain = Promise.resolve(); + files.forEach(function (f) { + chain = chain.then(function () { + const fd = new FormData(); + fd.append("file", f, f.name); + return fetch(url, { method: "POST", headers: headers, credentials: "[redacted:secret-assignment]", body: fd }) + .then(function (resp) { + if (!resp.ok) { + return resp.text().then(function (txt) { + throw new Error(parseApiErrorMessage(new Error(resp.status + ": " + txt))); + }); + } + }); + }); + }); + chain.then(function () { + load(); + props.onRefresh(); + }).catch(function (e) { + setUploadErr(String(e.message || e)); + }).finally(function () { + setUploadBusy(false); + }); + }; + + const handleDeleteAttachment = function (attachmentId) { + return SDK.fetchJSON(withBoard(`${API}/attachments/${attachmentId}`, boardSlug), { method: "DELETE" }) + .then(function () { load(); props.onRefresh(); }) + .catch(function (e) { setUploadErr(String(e.message || e)); }); + }; + const doPatch = function (patch, opts) { if (opts && opts.confirm && !window.confirm(opts.confirm)) { return Promise.resolve(); @@ -2946,6 +2991,10 @@ homeBusy: homeBusy, onToggleHomeSub: toggleHomeSubscription, onRefresh: props.onRefresh, + onUpload: handleUpload, + onDeleteAttachment: handleDeleteAttachment, + uploadBusy: uploadBusy, + uploadErr: uploadErr, }) : null, data ? h("div", { className: "hermes-kanban-drawer-comment-row" }, h(Input, { @@ -2968,11 +3017,118 @@ ); } + function _fmtBytes(n) { + n = Number(n) || 0; + if (n < 1024) return n + " B"; + if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB"; + return (n / (1024 * 1024)).toFixed(1) + " MB"; + } + + // Attachments section in the task drawer (#35338). Upload button + + // list with download links and a delete (×) per row. The download + // link hits GET /attachments/:id which streams the file; the worker + // context surfaces the same files' absolute paths so a kanban worker + // can read them with the file/terminal tools. + function AttachmentsSection(props) { + const i18n = props.i18n; + const atts = props.attachments || []; + const fileRef = useRef(null); + const [dlErr, setDlErr] = useState(null); + // Download via authenticated fetch → blob → synthetic anchor click. + // A plain <a href> can't carry the session header/bearer the dashboard + // auth middleware requires in loopback mode, so fetch with the token + // and hand the browser a blob URL instead. + function downloadAttachment(a) { + const token = [redacted:secret-assignment] || ""; + const headers = token ? { Authorization: "Bearer " + token } : {}; + const url = withBoard(`${API}/attachments/${a.id}`, props.boardSlug); + setDlErr(null); + fetch(url, { headers: headers, credentials: "[redacted:secret-assignment]" }) + .then(function (resp) { + if (!resp.ok) { + return resp.text().then(function (txt) { + throw new Error(parseApiErrorMessage(new Error(resp.status + ": " + txt))); + }); + } + return resp.blob(); + }) + .then(function (blob) { + const objUrl = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = objUrl; + link.download = a.filename || "attachment"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + setTimeout(function () { URL.revokeObjectURL(objUrl); }, 10000); + }) + .catch(function (e) { setDlErr(String(e.message || e)); }); + } + return h("div", { className: "hermes-kanban-section" }, + h("div", { className: "hermes-kanban-section-head" }, + `${tx(i18n, "attachments", "Attachments")} (${atts.length})`), + h("input", { + ref: fileRef, + type: "file", + multiple: true, + style: { display: "none" }, + onChange: function (e) { + if (props.onUpload) props.onUpload(e.target.files); + // Reset so selecting the same file again re-triggers onChange. + try { e.target.value = ""; } catch (_e) { /* ignore */ } + }, + }), + h("div", { className: "flex items-center gap-2 mb-2" }, + h(Button, { + size: "sm", + variant: "outline", + disabled: !!props.uploadBusy, + onClick: function () { if (fileRef.current) fileRef.current.click(); }, + }, props.uploadBusy + ? tx(i18n, "uploading", "Uploading…") + : tx(i18n, "uploadFile", "Upload file")), + ), + (props.uploadErr || dlErr) + ? h("div", { className: "text-xs text-destructive mb-2" }, props.uploadErr || dlErr) + : null, + atts.length === 0 + ? h("div", { className: "text-xs text-muted-foreground" }, + tx(i18n, "noAttachments", "— no attachments —")) + : atts.map(function (a) { + return h("div", { + key: a.id, + className: "flex items-center justify-between gap-2 py-1 text-sm", + }, + h("button", { + type: "button", + className: "hermes-kanban-attachment-link truncate", + title: a.filename, + onClick: function () { downloadAttachment(a); }, + }, a.filename), + h("span", { className: "text-xs text-muted-foreground whitespace-nowrap" }, + _fmtBytes(a.size)), + h("button", { + type: "button", + className: "hermes-kanban-drawer-close", + title: tx(i18n, "removeAttachment", "Remove attachment"), + onClick: function () { + if (window.confirm(tx(i18n, "confirmRemoveAttachment", + "Remove this attachment?"))) { + if (props.onDelete) props.onDelete(a.id); + } + }, + }, "×"), + ); + }), + ); + } + function TaskDetail(props) { const { t: i18n } = useI18n(); const t = props.data.task; const comments = props.data.comments || []; const events = props.data.events || []; + const attachments = props.data.attachments || []; const links = props.data.links || { parents: [], children: [] }; return h("div", { className: "hermes-kanban-drawer-body" }, @@ -3042,6 +3198,15 @@ h("div", { className: "hermes-kanban-section-head" }, tx(i18n, "result", "Result")), h(MarkdownBlock, { source: t.result, enabled: props.renderMarkdown }), ) : null, + h(AttachmentsSection, { + attachments: attachments, + boardSlug: props.boardSlug, + onUpload: props.onUpload, + onDelete: props.onDeleteAttachment, + uploadBusy: props.uploadBusy, + uploadErr: props.uploadErr, + i18n: i18n, + }), h("div", { className: "hermes-kanban-section" }, h("div", { className: "hermes-kanban-section-head" }, `${tx(i18n, "comments", "Comments")} (${comments.length})`), -
plugins/kanban/dashboard/dist/style.css@@ -386,6 +386,25 @@ } .hermes-kanban-drawer-close:hover { color: var(--color-foreground); } +/* Attachment download trigger — styled as a link, rendered as a <button> + so the click handler can fetch with the session token (#35338). */ +.hermes-kanban-attachment-link { + appearance: none; + background: transparent; + border: 0; + padding: 0; + margin: 0; + text-align: left; + color: var(--color-primary, #6ea8fe); + cursor: pointer; + text-decoration: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} +.hermes-kanban-attachment-link:hover { text-decoration: underline; } + .hermes-kanban-drawer-body { flex: 1; overflow-y: auto; -
plugins/kanban/dashboard/plugin_api.py@@ -43,9 +43,11 @@ import sqlite3 import time from dataclasses import asdict +from pathlib import Path from typing import Any, Optional -from fastapi import APIRouter, HTTPException, Query, WebSocket, WebSocketDisconnect, status as http_status +from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile, WebSocket, WebSocketDisconnect, status as http_status +from fastapi.responses import FileResponse from pydantic import BaseModel, Field from hermes_cli import kanban_db @@ -186,6 +188,21 @@ def _comment_dict(c: kanban_db.Comment) -> dict[str, Any]: } +def _attachment_dict(a: kanban_db.Attachment) -> dict[str, Any]: + """Serialise an Attachment for the drawer. ``stored_path`` is the + absolute on-disk path workers read; the UI uses ``id`` for download.""" + return { + "id": a.id, + "task_id": a.task_id, + "filename": a.filename, + "content_type": a.content_type, + "size": a.size, + "uploaded_by": a.uploaded_by, + "stored_path": a.stored_path, + "created_at": a.created_at, + } + + def _run_dict(r: kanban_db.Run) -> dict[str, Any]: """Serialise a Run for the drawer's Run history section.""" return { @@ -531,6 +548,7 @@ def get_task( "task": task_d, "comments": [_comment_dict(c) for c in kanban_db.list_comments(conn, task_id)], "events": [_event_dict(e) for e in kanban_db.list_events(conn, task_id)], + "attachments": [_attachment_dict(a) for a in kanban_db.list_attachments(conn, task_id)], "links": _links_for(conn, task_id), "runs": [ _run_dict(r) @@ -609,6 +627,165 @@ def create_task(payload: CreateTaskBody, board: Optional[str] = Query(None)): conn.close() +# --------------------------------------------------------------------------- +# Attachments — upload / list / download / delete (#35338) +# --------------------------------------------------------------------------- + +# Cap a single upload so a runaway request can't fill the disk. 25 MB +# comfortably covers PDFs, images, and source docs — the kanban use case. +_MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024 + + +def _safe_attachment_name(raw: str) -> str: + """Reduce a client-supplied filename to a safe basename. + + Strips any directory components (``os.path.basename`` on both + separators) so a malicious ``../../etc/passwd`` or ``C:\\x`` collapses + to its leaf. Rejects empty / dotfile-only names. The result is only + ever joined under the per-task attachments dir, never used verbatim + as a path from the client. + """ + name = (raw or "").replace("\\", "/").split("/")[-1].strip() + # Drop control chars and leading dots so we never write a dotfile or + # a name with embedded NULs/newlines. + name = "".join(ch for ch in name if ch.isprintable() and ch not in '\x00').strip() + name = name.lstrip(".").strip() + if not name: + raise HTTPException(status_code=400, detail="invalid attachment filename") + return name[:200] + + [redacted:email]("/tasks/{task_id}/attachments") +def list_task_attachments(task_id: str, board: Optional[str] = Query(None)): + board = _resolve_board(board) + conn = _conn(board=board) + try: + if kanban_db.get_task(conn, task_id) is None: + raise HTTPException(status_code=404, detail=f"task {task_id} not found") + return { + "attachments": [ + _attachment_dict(a) for a in kanban_db.list_attachments(conn, task_id) + ] + } + finally: + conn.close() + + [redacted:email]("/tasks/{task_id}/attachments") +async def upload_task_attachment( + task_id: str, + file: UploadFile = File(...), + board: Optional[str] = Query(None), + uploaded_by: Optional[str] = Form(None), +): + """Store an uploaded file for a task and record its metadata. + + The blob lands under ``attachments_root(board)/<task_id>/`` with a + sanitised, collision-resolved name. The worker reads it via the + absolute path surfaced in ``build_worker_context``. + """ + board = _resolve_board(board) + conn = _conn(board=board) + try: + if kanban_db.get_task(conn, task_id) is None: + raise HTTPException(status_code=404, detail=f"task {task_id} not found") + + safe_name = _safe_attachment_name(file.filename or "") + + # Stream to disk with a hard size cap so a huge upload can't fill + # the disk. Read in chunks; abort + clean up if the cap is hit. + dest_dir = kanban_db.task_attachments_dir(task_id, board=board) + dest_dir.mkdir(parents=True, exist_ok=True) + + # Resolve name collisions: foo.pdf → foo (1).pdf, foo (2).pdf, … + stem, dot, ext = safe_name.partition(".") + candidate = safe_name + n = 1 + while (dest_dir / candidate).exists(): + candidate = f"{stem} ({n}){dot}{ext}" + n += 1 + dest_path = dest_dir / candidate + + total = 0 + try: + with open(dest_path, "wb") as out: + while True: + chunk = await file.read(1024 * 1024) + if not chunk: + break + total += len(chunk) + if total > _MAX_ATTACHMENT_BYTES: + out.close() + dest_path.unlink(missing_ok=True) + raise HTTPException( + status_code=413, + detail=( + f"attachment exceeds {_MAX_ATTACHMENT_BYTES // (1024 * 1024)} MB limit" + ), + ) + out.write(chunk) + except HTTPException: + raise + except OSError as exc: + raise HTTPException(status_code=500, detail=f"failed to store attachment: {exc}") + + att_id = kanban_db.add_attachment( + conn, + task_id, + filename=candidate, + stored_path=str(dest_path.resolve()), + content_type=file.content_type, + size=total, + uploaded_by=(uploaded_by or "dashboard"), + ) + att = kanban_db.get_attachment(conn, att_id) + return {"attachment": _attachment_dict(att) if att else None} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + finally: + conn.close() + + [redacted:email]("/attachments/{attachment_id}") +def download_attachment(attachment_id: int, board: Optional[str] = Query(None)): + board = _resolve_board(board) + conn = _conn(board=board) + try: + att = kanban_db.get_attachment(conn, attachment_id) + if att is None: + raise HTTPException(status_code=404, detail="attachment not found") + # Confirm the blob still lives under the board's attachments root + # before serving — defense in depth against a tampered DB row. + root = kanban_db.attachments_root(board=board).resolve() + try: + stored = Path(att.stored_path).resolve() + stored.relative_to(root) + except (ValueError, OSError): + raise HTTPException(status_code=404, detail="attachment file unavailable") + if not stored.is_file(): + raise HTTPException(status_code=404, detail="attachment file missing on disk") + return FileResponse( + path=str(stored), + filename=att.filename, + media_type=att.content_type or "application/octet-stream", + ) + finally: + conn.close() + + [redacted:email]("/attachments/{attachment_id}") +def remove_attachment(attachment_id: int, board: Optional[str] = Query(None)): + board = _resolve_board(board) + conn = _conn(board=board) + try: + att = kanban_db.delete_attachment(conn, attachment_id) + if att is None: + raise HTTPException(status_code=404, detail="attachment not found") + return {"ok": True, "id": attachment_id} + finally: + conn.close() + + # --------------------------------------------------------------------------- # PATCH /tasks/:id (status / assignee / priority / title / body) # --------------------------------------------------------------------------- -
tests/plugins/test_kanban_attachments.py@@ -0,0 +1,291 @@ +"""Tests for Kanban task file attachments (#35338). + +Covers three layers: + * ``hermes_cli.kanban_db`` accessors (add/list/get/delete + path helpers) + * the dashboard REST surface (upload / list / download / delete) + * worker-context surfacing so a kanban worker sees the absolute paths + +The plugin router is attached to a bare FastAPI app — same approach as +``test_kanban_dashboard_plugin.py`` — so we exercise the real HTTP path +(multipart upload, streaming download) without the whole dashboard. +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from hermes_cli import kanban_db as kb + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +def _load_plugin_router(): + repo_root = Path(__file__).resolve().parents[2] + plugin_file = repo_root / "plugins" / "kanban" / "dashboard" / "plugin_api.py" + assert plugin_file.exists(), f"plugin file missing: {plugin_file}" + spec = importlib.util.spec_from_file_location( + "hermes_dashboard_plugin_kanban_attach_test", plugin_file, + ) + assert spec is not None and spec.loader is not None + mod = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = mod + spec.loader.exec_module(mod) + return mod.router + + [redacted:email] +def kanban_home(tmp_path, monkeypatch): + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + kb.init_db() + return home + + [redacted:email] +def client(kanban_home): + app = FastAPI() + app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban") + return TestClient(app) + + +def _make_task(conn, title="t") -> str: + return kb.create_task(conn, title=title) + + +# --------------------------------------------------------------------------- +# DB-layer accessors +# --------------------------------------------------------------------------- + + +def test_add_list_get_delete_attachment(kanban_home, tmp_path): + conn = kb.connect() + try: + task_id = _make_task(conn) + # Write a real blob under the per-task dir so delete can unlink it. + dest_dir = kb.task_attachments_dir(task_id) + dest_dir.mkdir(parents=True, exist_ok=True) + blob = dest_dir / "source.pdf" + blob.write_bytes(b"%PDF-1.4 fake") + + att_id = kb.add_attachment( + conn, + task_id, + filename="source.pdf", + stored_path=str(blob), + content_type="application/pdf", + size=blob.stat().st_size, + uploaded_by="tester", + ) + assert att_id > 0 + + atts = kb.list_attachments(conn, task_id) + assert len(atts) == 1 + a = atts[0] + assert a.filename == "source.pdf" + assert a.content_type == "application/pdf" + assert a.size == len(b"%PDF-1.4 fake") + assert a.uploaded_by == "tester" + assert a.stored_path == str(blob) + + got = kb.get_attachment(conn, att_id) + assert got is not None and got.id == att_id + + removed = kb.delete_attachment(conn, att_id) + assert removed is not None and removed.id == att_id + assert kb.list_attachments(conn, task_id) == [] + assert not blob.exists(), "delete should unlink the on-disk blob" + assert kb.get_attachment(conn, att_id) is None + finally: + conn.close() + + +def test_add_attachment_rejects_unknown_task(kanban_home): + conn = kb.connect() + try: + with pytest.raises(ValueError): + kb.add_attachment( + conn, "t_doesnotexist", filename="x.txt", stored_path="/tmp/x.txt" + ) + finally: + conn.close() + + +def test_add_attachment_appends_event(kanban_home): + conn = kb.connect() + try: + task_id = _make_task(conn) + kb.add_attachment( + conn, task_id, filename="a.txt", stored_path="/tmp/a.txt", size=3 + ) + kinds = [e.kind for e in kb.list_events(conn, task_id)] + assert "attached" in kinds + finally: + conn.close() + + +def test_delete_attachment_missing_returns_none(kanban_home): + conn = kb.connect() + try: + assert kb.delete_attachment(conn, 999999) is None + finally: + conn.close() + + +def test_attachments_root_is_per_board(kanban_home, monkeypatch): + # default board uses <root>/kanban/attachments + default_root = kb.attachments_root(board="default") + assert default_root.name == "attachments" + # a named board nests under its board dir + monkeypatch.delenv("HERMES_KANBAN_ATTACHMENTS_ROOT", raising=False) + named = kb.attachments_root(board="default") + assert named == default_root + + +def test_attachments_root_env_override(kanban_home, monkeypatch, tmp_path): + override = tmp_path / "custom-attach" + monkeypatch.setenv("HERMES_KANBAN_ATTACHMENTS_ROOT", str(override)) + assert kb.attachments_root() == override + assert kb.task_attachments_dir("t_abc") == override / "t_abc" + + +# --------------------------------------------------------------------------- +# Worker context surfacing +# --------------------------------------------------------------------------- + + +def test_worker_context_lists_attachments_with_absolute_path(kanban_home): + conn = kb.connect() + try: + task_id = _make_task(conn, title="translate PDF") + dest_dir = kb.task_attachments_dir(task_id) + dest_dir.mkdir(parents=True, exist_ok=True) + blob = dest_dir / "manual.pdf" + blob.write_bytes(b"data") + kb.add_attachment( + conn, + task_id, + filename="manual.pdf", + stored_path=str(blob.resolve()), + content_type="application/pdf", + size=4, + ) + ctx = kb.build_worker_context(conn, task_id) + assert "## Attachments" in ctx + assert "manual.pdf" in ctx + # The absolute path must appear so the worker can read_file it. + assert str(blob.resolve()) in ctx + finally: + conn.close() + + +def test_worker_context_no_attachments_section_when_empty(kanban_home): + conn = kb.connect() + try: + task_id = _make_task(conn) + ctx = kb.build_worker_context(conn, task_id) + assert "## Attachments" not in ctx + finally: + conn.close() + + +# --------------------------------------------------------------------------- +# REST surface — upload / list / download / delete round-trip +# --------------------------------------------------------------------------- + + +def _create_task_via_api(client) -> str: + r = client.post("/api/plugins/kanban/tasks", json={"title": "x"}) + assert r.status_code == 200, r.text + return r.json()["task"]["id"] + + +def test_upload_list_download_delete_roundtrip(client): + task_id = _create_task_via_api(client) + content = b"hello attachment world" + + # Upload + r = client.post( + f"/api/plugins/kanban/tasks/{task_id}/attachments", + files={"file": ("notes.txt", content, "text/plain")}, + ) + assert r.status_code == 200, r.text + att = r.json()["attachment"] + assert att["filename"] == "notes.txt" + assert att["size"] == len(content) + att_id = att["id"] + + # List (drawer also embeds it in GET /tasks/:id) + r = client.get(f"/api/plugins/kanban/tasks/{task_id}/attachments") + assert r.status_code == 200 + assert [a["filename"] for a in r.json()["attachments"]] == ["notes.txt"] + + detail = client.get(f"/api/plugins/kanban/tasks/{task_id}").json() + assert "attachments" in detail + assert len(detail["attachments"]) == 1 + + # Download streams the exact bytes back + r = client.get(f"/api/plugins/kanban/attachments/{att_id}") + assert r.status_code == 200 + assert r.content == content + + # Delete removes the row and the file + r = client.delete(f"/api/plugins/kanban/attachments/{att_id}") + assert r.status_code == 200 + assert client.get(f"/api/plugins/kanban/attachments/{att_id}").status_code == 404 + assert client.get( + f"/api/plugins/kanban/tasks/{task_id}/attachments" + ).json()["attachments"] == [] + + +def test_upload_sanitizes_traversal_filename(client): + task_id = _create_task_via_api(client) + r = client.post( + f"/api/plugins/kanban/tasks/{task_id}/attachments", + files={"file": ("../../../../etc/passwd", b"x", "text/plain")}, + ) + assert r.status_code == 200, r.text + stored_path = r.json()["attachment"]["stored_path"] + # The leaf name only; never escapes the per-task attachments dir. + assert Path(stored_path).name == "passwd" + task_dir = kb.task_attachments_dir(task_id).resolve() + assert Path(stored_path).resolve().is_relative_to(task_dir) + + +def test_upload_name_collision_gets_suffixed(client): + task_id = _create_task_via_api(client) + for _ in range(2): + r = client.post( + f"/api/plugins/kanban/tasks/{task_id}/attachments", + files={"file": ("dup.txt", b"a", "text/plain")}, + ) + assert r.status_code == 200, r.text + names = sorted( + a["filename"] + for a in client.get( + f"/api/plugins/kanban/tasks/{task_id}/attachments" + ).json()["attachments"] + ) + assert names == ["dup (1).txt", "dup.txt"] + + +def test_upload_unknown_task_404(client): + r = client.post( + "/api/plugins/kanban/tasks/t_nope/attachments", + files={"file": ("x.txt", b"x", "text/plain")}, + ) + assert r.status_code == 404 + + +def test_download_unknown_attachment_404(client): + assert client.get("/api/plugins/kanban/attachments/424242").status_code == 404 -
website/docs/user-guide/features/kanban.md@@ -155,6 +155,36 @@ events WebSocket is pinned to a board at connection time; switching in the UI opens a fresh WS against the new board. +## File attachments + +Tasks can carry file attachments — PDFs, images, source documents — so a +worker has the source material it needs without you pasting paths into the +body and hoping it finds them. + +- **Upload** — open a task in the dashboard drawer and use the + **Attachments** section's *Upload file* button (multiple files at once + are fine). Each upload is capped at 25 MB. +- **Storage** — files land under + `<hermes-home>/kanban/attachments/<task_id>/` for the default board, or + `<hermes-home>/kanban/boards/<slug>/attachments/<task_id>/` for a named + board. Set `HERMES_KANBAN_ATTACHMENTS_ROOT` to pin a custom location. +- **What the worker sees** — when the dispatcher hands a task to a worker, + the worker's context includes an **Attachments** section listing each + file's name and its **absolute path**. The worker has full file/terminal + tool access, so it reads attachments directly (`read_file`, or shell + tools like `pdftotext`). +- **Download / remove** — the drawer lists each attachment with a download + link and a remove (×) control. Removing an attachment deletes both the + metadata row and the on-disk file. + +:::note Remote terminal backends +Attachment paths resolve directly on the **local** terminal backend, which +is the default for Kanban workers. If you run workers on a remote backend +(Docker, Modal), mount the board's `attachments/` directory into the +sandbox so the absolute paths in the worker context are reachable. +::: + + ## Quick start The commands below are **you** (the human) setting up the board and creating tasks. Once a task is assigned, the dispatcher spawns the assigned profile as a worker, and from there **the model drives the task through `kanban_*` tool calls, not CLI commands** — see [How workers interact with the board](#how-workers-interact-with-the-board).
-
Closed issues · 1
- Closed#35338File upload in Kanban tasks
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- dashboard
- Merged PRs
- #35395
- Closed issues
- 1
- Attempts
- 0 PRs closed without merging
- Fix link checked
- 30 May 2026