mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:29:37 +00:00
feat(kanban): attachment toolset + CLI to match the dashboard surface
The kanban board has had full attachment storage and a dashboard HTTP API (upload/list/download/delete) since #35338, but there was no agent toolset tool and no `hermes kanban` CLI verb for attachments. Agents and scripts that don't go through the dashboard server (or can't touch the DB directly) had no way to create or read real attachments — only links in comments. Close that gap by mirroring the existing comment surface: - `kanban_db.store_attachment_bytes()` — one shared write path (validate name, enforce the 25 MB cap, write the blob under the per-task dir with collision-free naming, insert the metadata row, clean up an orphan blob if the insert fails). `_MAX_ATTACHMENT_BYTES`, `_safe_attachment_name`, and a new `_collision_free_path` move here so the dashboard, the tool, and the CLI all share one implementation and can't drift. - Tools (`tools/kanban_tools.py`): `kanban_attach` (inline base64), `kanban_attach_url` (server-side http/https fetch with the same cap), `kanban_attachments` (list). Write tools respect worker task-ownership; list is read-only. Registered in the `kanban` toolset. - CLI (`hermes_cli/kanban.py`): `attach <id> <path>`, `attachments <id>`, `attach-rm <attachment_id>`. - Dashboard `upload_task_attachment` now imports the shared helpers and uses `_collision_free_path` — behavior identical (still streams to disk with the cap, still 413 on overflow). - Docs (AGENTS.md, kanban-worker skill) and toolset membership updated. Tests: tool round-trip + oversize + bad base64 + ownership; attach_url against a local HTTP fixture incl. oversize-mid-stream and non-http scheme rejection; CLI attach/attachments/attach-rm; shared-helper unit tests; dashboard parity preserved. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
14f023cd00
commit
3fccd698fd
9 changed files with 930 additions and 39 deletions
|
|
@ -522,6 +522,24 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
|
|||
p_comment.add_argument("--max-len", type=int, default=None,
|
||||
help="Trim the stored comment body to this many characters")
|
||||
|
||||
# --- attach / attachments / attach-rm ---
|
||||
p_attach = sub.add_parser("attach", help="Attach a local file to a task")
|
||||
p_attach.add_argument("task_id")
|
||||
p_attach.add_argument("path", help="Path to the local file to attach")
|
||||
p_attach.add_argument("--content-type", default=None,
|
||||
help="MIME type (default: guessed from the file extension)")
|
||||
p_attach.add_argument("--name", default=None,
|
||||
help="Stored filename (default: the source file's basename)")
|
||||
p_attach.add_argument("--author", default=None,
|
||||
help="uploaded_by label (default: $HERMES_PROFILE or 'user')")
|
||||
|
||||
p_attachments = sub.add_parser("attachments", help="List a task's attachments")
|
||||
p_attachments.add_argument("task_id")
|
||||
p_attachments.add_argument("--json", action="store_true")
|
||||
|
||||
p_attach_rm = sub.add_parser("attach-rm", help="Delete an attachment by id")
|
||||
p_attach_rm.add_argument("attachment_id", type=int)
|
||||
|
||||
p_complete = sub.add_parser("complete", help="Mark one or more tasks done")
|
||||
p_complete.add_argument("task_ids", nargs="+",
|
||||
help="One or more task ids (only --result applies to all of them)")
|
||||
|
|
@ -951,6 +969,9 @@ def kanban_command(args: argparse.Namespace) -> int:
|
|||
"unlink": _cmd_unlink,
|
||||
"claim": _cmd_claim,
|
||||
"comment": _cmd_comment,
|
||||
"attach": _cmd_attach,
|
||||
"attachments": _cmd_attachments,
|
||||
"attach-rm": _cmd_attach_rm,
|
||||
"complete": _cmd_complete,
|
||||
"edit": _cmd_edit,
|
||||
"block": _cmd_block,
|
||||
|
|
@ -1851,6 +1872,84 @@ def _cmd_comment(args: argparse.Namespace) -> int:
|
|||
return 0
|
||||
|
||||
|
||||
def _cmd_attach(args: argparse.Namespace) -> int:
|
||||
"""Attach a local file to a task.
|
||||
|
||||
Reads the file off disk, writes it under the task's attachments dir,
|
||||
and records the metadata row via the shared ``store_attachment_bytes``
|
||||
path (same code the dashboard upload and the agent tool use), so the
|
||||
25 MB cap and name-sanitisation behave identically everywhere.
|
||||
"""
|
||||
import mimetypes
|
||||
|
||||
src = Path(args.path).expanduser()
|
||||
if not src.is_file():
|
||||
print(f"kanban: no such file: {src}", file=sys.stderr)
|
||||
return 1
|
||||
data = src.read_bytes()
|
||||
name = args.name or src.name
|
||||
content_type = args.content_type or mimetypes.guess_type(name)[0]
|
||||
uploaded_by = args.author or _profile_author()
|
||||
try:
|
||||
with kb.connect_closing() as conn:
|
||||
att_id = kb.store_attachment_bytes(
|
||||
conn,
|
||||
args.task_id,
|
||||
name,
|
||||
data,
|
||||
content_type=content_type,
|
||||
uploaded_by=uploaded_by,
|
||||
)
|
||||
except kb.AttachmentTooLarge as exc:
|
||||
print(f"kanban: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
print(f"Attached {name} to {args.task_id} (attachment {att_id}, {len(data)} bytes)")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_attachments(args: argparse.Namespace) -> int:
|
||||
"""List a task's attachments."""
|
||||
with kb.connect_closing() as conn:
|
||||
if kb.get_task(conn, args.task_id) is None:
|
||||
print(f"no such task: {args.task_id}", file=sys.stderr)
|
||||
return 1
|
||||
atts = kb.list_attachments(conn, args.task_id)
|
||||
if getattr(args, "json", False):
|
||||
print(json.dumps([
|
||||
{
|
||||
"id": a.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,
|
||||
}
|
||||
for a in atts
|
||||
], indent=2))
|
||||
return 0
|
||||
if not atts:
|
||||
print(f"No attachments on {args.task_id}")
|
||||
return 0
|
||||
print(f"Attachments on {args.task_id}:")
|
||||
for a in atts:
|
||||
ct = a.content_type or "-"
|
||||
print(f" [{a.id}] {a.filename} ({a.size} bytes, {ct}, by {a.uploaded_by or '-'})")
|
||||
print(f" {a.stored_path}")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_attach_rm(args: argparse.Namespace) -> int:
|
||||
"""Delete an attachment by id (removes the row and the on-disk blob)."""
|
||||
with kb.connect_closing() as conn:
|
||||
removed = kb.delete_attachment(conn, args.attachment_id)
|
||||
if removed is None:
|
||||
print(f"no such attachment: {args.attachment_id}", file=sys.stderr)
|
||||
return 1
|
||||
print(f"Deleted attachment {args.attachment_id} ({removed.filename}) from {removed.task_id}")
|
||||
return 0
|
||||
|
||||
|
||||
def _worker_run_id_for(task_id: str) -> Optional[int]:
|
||||
if os.environ.get("HERMES_KANBAN_TASK") != task_id:
|
||||
return None
|
||||
|
|
@ -2753,6 +2852,7 @@ Common subcommands:
|
|||
`stats` Per-status / per-assignee counts
|
||||
`create <title>…` Create a task (auto-subscribes you to events)
|
||||
`comment <id> <msg>` Append a comment
|
||||
`attach <id> <path>` Attach a local file; `attachments <id>` to list
|
||||
`complete <id>…` Mark task(s) done
|
||||
`block <id> [reason]` Mark blocked; `schedule <id> [reason]` parks time-delay work; `unblock <id>` to revive
|
||||
`assign <id> <profile>` Reassign
|
||||
|
|
|
|||
|
|
@ -2963,6 +2963,118 @@ def list_comments(conn: sqlite3.Connection, task_id: str) -> list[Comment]:
|
|||
# Attachments
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Cap a single attachment so a runaway upload can't fill the disk. 25 MB
|
||||
# comfortably covers PDFs, images, and source docs — the kanban use case.
|
||||
# Shared by the dashboard HTTP endpoint, the agent toolset, and the CLI so
|
||||
# the limit cannot drift between surfaces.
|
||||
_MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024
|
||||
|
||||
|
||||
class AttachmentTooLarge(ValueError):
|
||||
"""Raised when an attachment exceeds the configured size cap.
|
||||
|
||||
Subclasses :class:`ValueError` so generic ``except ValueError`` handlers
|
||||
(e.g. the dashboard's 400 fallback) still catch it, while callers that
|
||||
want a distinct user-facing message (the tool/CLI 413-equivalent) can
|
||||
catch it specifically.
|
||||
"""
|
||||
|
||||
|
||||
def _safe_attachment_name(raw: str) -> str:
|
||||
"""Reduce a client-supplied filename to a safe basename.
|
||||
|
||||
Strips any directory components (both separators) so a malicious
|
||||
``../../etc/passwd`` or ``C:\\x`` collapses to its leaf. Drops control
|
||||
chars and leading dots so we never write a dotfile or a name with
|
||||
embedded NULs/newlines. 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.
|
||||
|
||||
Raises :class:`ValueError` on an unusable name; HTTP callers map that
|
||||
to a 400.
|
||||
"""
|
||||
name = (raw or "").replace("\\", "/").split("/")[-1].strip()
|
||||
name = "".join(ch for ch in name if ch.isprintable() and ch not in "\x00").strip()
|
||||
name = name.lstrip(".").strip()
|
||||
if not name:
|
||||
raise ValueError("invalid attachment filename")
|
||||
return name[:200]
|
||||
|
||||
|
||||
def _collision_free_path(dest_dir: Path, safe_name: str) -> Path:
|
||||
"""Return a path under ``dest_dir`` that doesn't clobber an existing file.
|
||||
|
||||
``foo.pdf`` → ``foo.pdf``, then ``foo (1).pdf``, ``foo (2).pdf``, …
|
||||
``safe_name`` must already be sanitised via :func:`_safe_attachment_name`.
|
||||
"""
|
||||
stem, dot, ext = safe_name.partition(".")
|
||||
candidate = safe_name
|
||||
n = 1
|
||||
while (dest_dir / candidate).exists():
|
||||
candidate = f"{stem} ({n}){dot}{ext}"
|
||||
n += 1
|
||||
return dest_dir / candidate
|
||||
|
||||
|
||||
def store_attachment_bytes(
|
||||
conn: sqlite3.Connection,
|
||||
task_id: str,
|
||||
filename: str,
|
||||
data: bytes,
|
||||
*,
|
||||
content_type: Optional[str] = None,
|
||||
uploaded_by: Optional[str] = None,
|
||||
board: Optional[str] = None,
|
||||
max_bytes: Optional[int] = None,
|
||||
) -> int:
|
||||
"""Validate, size-check, persist a blob, and record its metadata row.
|
||||
|
||||
This is the single write path shared by the dashboard endpoint, the
|
||||
agent toolset (``kanban_attach`` / ``kanban_attach_url``), and the CLI
|
||||
(``hermes kanban attach``) so name-sanitisation, the size cap, and the
|
||||
collision-resolution all behave identically everywhere.
|
||||
|
||||
Steps: enforce ``max_bytes``, sanitise ``filename`` to a safe basename,
|
||||
write the bytes under :func:`task_attachments_dir` with a
|
||||
collision-free name, then insert the ``task_attachments`` row via
|
||||
:func:`add_attachment`. Returns the new attachment id.
|
||||
|
||||
Raises :class:`AttachmentTooLarge` when ``data`` exceeds ``max_bytes``,
|
||||
or :class:`ValueError` for a bad filename / unknown task. On any failure
|
||||
after the blob is written (e.g. the task disappeared) the orphaned blob
|
||||
is removed before re-raising.
|
||||
"""
|
||||
if max_bytes is None:
|
||||
max_bytes = _MAX_ATTACHMENT_BYTES
|
||||
if len(data) > max_bytes:
|
||||
raise AttachmentTooLarge(
|
||||
f"attachment exceeds {max_bytes // (1024 * 1024)} MB limit"
|
||||
)
|
||||
safe_name = _safe_attachment_name(filename)
|
||||
dest_dir = task_attachments_dir(task_id, board=board)
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest_path = _collision_free_path(dest_dir, safe_name)
|
||||
dest_path.write_bytes(data)
|
||||
try:
|
||||
return add_attachment(
|
||||
conn,
|
||||
task_id,
|
||||
filename=dest_path.name,
|
||||
stored_path=str(dest_path.resolve()),
|
||||
content_type=content_type,
|
||||
size=len(data),
|
||||
uploaded_by=uploaded_by,
|
||||
)
|
||||
except Exception:
|
||||
# Don't leave an orphan blob if the metadata insert fails (most
|
||||
# commonly: the task id doesn't exist).
|
||||
try:
|
||||
dest_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def add_attachment(
|
||||
conn: sqlite3.Connection,
|
||||
task_id: str,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue