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

No open issues.

Open PRs1

  • PR#35596fix(kanban): delete partial attachment file on OSError during upload

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #35395 feat(kanban): file attachments on tasks (#35338)

    merged to main

    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.md

    Show 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.00 means 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 Lab

At a glance

Subsystem
dashboard
Merged PRs
#35395
Closed issues
1
Attempts
0 PRs closed without merging
Fix link checked
30 May 2026

For your agent

The same facts, typed.