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