mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +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
|
|
@ -890,7 +890,7 @@ def test_cli_gc_reports_counts(kanban_home):
|
|||
# run_slash parity — every verb returns a sensible, non-crashy string
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_run_slash_every_verb_returns_sensible_output(kanban_home):
|
||||
def test_run_slash_every_verb_returns_sensible_output(kanban_home, tmp_path):
|
||||
"""Smoke-test every verb with minimal args. None may raise, none may
|
||||
return the empty string (must either succeed or report a usage error)."""
|
||||
# Set up a pair of tasks to reference.
|
||||
|
|
@ -901,6 +901,9 @@ def test_run_slash_every_verb_returns_sensible_output(kanban_home):
|
|||
finally:
|
||||
conn.close()
|
||||
|
||||
attach_src = tmp_path / "smoke.txt"
|
||||
attach_src.write_text("smoke")
|
||||
|
||||
invocations = [
|
||||
"", # no subcommand → help text
|
||||
"--help",
|
||||
|
|
@ -914,6 +917,8 @@ def test_run_slash_every_verb_returns_sensible_output(kanban_home):
|
|||
f"unlink {tid_a} {tid_b}",
|
||||
f"claim {tid_a}",
|
||||
f"comment {tid_a} hello",
|
||||
f"attach {tid_a} {attach_src}",
|
||||
f"attachments {tid_a}",
|
||||
f"complete {tid_a}",
|
||||
f"block {tid_b} need input",
|
||||
f"unblock {tid_b}",
|
||||
|
|
|
|||
|
|
@ -289,3 +289,149 @@ def test_upload_unknown_task_404(client):
|
|||
|
||||
def test_download_unknown_attachment_404(client):
|
||||
assert client.get("/api/plugins/kanban/attachments/424242").status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared helper — store_attachment_bytes (used by dashboard + tool + CLI)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_store_attachment_bytes_roundtrip(kanban_home):
|
||||
conn = kb.connect()
|
||||
try:
|
||||
task_id = _make_task(conn)
|
||||
att_id = kb.store_attachment_bytes(
|
||||
conn, task_id, "doc.txt", b"some bytes",
|
||||
content_type="text/plain", uploaded_by="tester",
|
||||
)
|
||||
a = kb.get_attachment(conn, att_id)
|
||||
assert a is not None
|
||||
assert a.filename == "doc.txt"
|
||||
assert a.size == len(b"some bytes")
|
||||
assert a.uploaded_by == "tester"
|
||||
assert Path(a.stored_path).read_bytes() == b"some bytes"
|
||||
assert Path(a.stored_path).resolve().is_relative_to(
|
||||
kb.task_attachments_dir(task_id).resolve()
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_store_attachment_bytes_rejects_oversize_and_leaves_no_blob(kanban_home):
|
||||
conn = kb.connect()
|
||||
try:
|
||||
task_id = _make_task(conn)
|
||||
with pytest.raises(kb.AttachmentTooLarge):
|
||||
kb.store_attachment_bytes(
|
||||
conn, task_id, "big.bin", b"0123456789", max_bytes=4,
|
||||
)
|
||||
assert kb.list_attachments(conn, task_id) == []
|
||||
# No partial blob left behind.
|
||||
d = kb.task_attachments_dir(task_id)
|
||||
assert not d.exists() or list(d.iterdir()) == []
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_store_attachment_bytes_resolves_collisions(kanban_home):
|
||||
conn = kb.connect()
|
||||
try:
|
||||
task_id = _make_task(conn)
|
||||
kb.store_attachment_bytes(conn, task_id, "dup.txt", b"a")
|
||||
kb.store_attachment_bytes(conn, task_id, "dup.txt", b"b")
|
||||
names = sorted(a.filename for a in kb.list_attachments(conn, task_id))
|
||||
assert names == ["dup (1).txt", "dup.txt"]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_store_attachment_bytes_unknown_task_leaves_no_blob(kanban_home):
|
||||
conn = kb.connect()
|
||||
try:
|
||||
with pytest.raises(ValueError):
|
||||
kb.store_attachment_bytes(conn, "t_nope", "x.txt", b"x")
|
||||
# The per-task dir may get created, but no blob should survive the
|
||||
# failed metadata insert.
|
||||
d = kb.task_attachments_dir("t_nope")
|
||||
assert not d.exists() or list(d.iterdir()) == []
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI — hermes kanban attach / attachments / attach-rm
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cli_attach_attachments_and_rm(kanban_home, tmp_path):
|
||||
from hermes_cli.kanban import run_slash
|
||||
|
||||
conn = kb.connect()
|
||||
try:
|
||||
task_id = _make_task(conn, title="cli-attach")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
src = tmp_path / "upload.txt"
|
||||
src.write_bytes(b"cli file body")
|
||||
|
||||
out = run_slash(f"attach {task_id} {src}")
|
||||
assert "Attached" in out, out
|
||||
|
||||
conn = kb.connect()
|
||||
try:
|
||||
atts = kb.list_attachments(conn, task_id)
|
||||
assert len(atts) == 1
|
||||
att_id = atts[0].id
|
||||
assert atts[0].filename == "upload.txt"
|
||||
assert Path(atts[0].stored_path).read_bytes() == b"cli file body"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
listed = run_slash(f"attachments {task_id}")
|
||||
assert "upload.txt" in listed
|
||||
|
||||
removed = run_slash(f"attach-rm {att_id}")
|
||||
assert "Deleted attachment" in removed
|
||||
conn = kb.connect()
|
||||
try:
|
||||
assert kb.list_attachments(conn, task_id) == []
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_cli_attach_honors_name_override(kanban_home, tmp_path):
|
||||
from hermes_cli.kanban import run_slash
|
||||
|
||||
conn = kb.connect()
|
||||
try:
|
||||
task_id = _make_task(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
src = tmp_path / "raw.bin"
|
||||
src.write_bytes(b"xyz")
|
||||
run_slash(f"attach {task_id} {src} --name renamed.dat")
|
||||
conn = kb.connect()
|
||||
try:
|
||||
assert kb.list_attachments(conn, task_id)[0].filename == "renamed.dat"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_cli_attach_missing_file(kanban_home, tmp_path):
|
||||
from hermes_cli.kanban import run_slash
|
||||
|
||||
conn = kb.connect()
|
||||
try:
|
||||
task_id = _make_task(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
out = run_slash(f"attach {task_id} {tmp_path / 'does-not-exist.txt'}")
|
||||
assert "no such file" in out.lower()
|
||||
|
||||
|
||||
def test_cli_attachments_unknown_task(kanban_home):
|
||||
from hermes_cli.kanban import run_slash
|
||||
|
||||
out = run_slash("attachments t_nope")
|
||||
assert "no such task" in out.lower()
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ def test_kanban_tools_visible_with_env_var(monkeypatch, tmp_path):
|
|||
expected = {
|
||||
"kanban_show", "kanban_complete", "kanban_block", "kanban_heartbeat",
|
||||
"kanban_comment", "kanban_create", "kanban_link",
|
||||
"kanban_attach", "kanban_attach_url", "kanban_attachments",
|
||||
}
|
||||
assert kanban == expected, f"expected {expected}, got {kanban}"
|
||||
|
||||
|
|
@ -138,6 +139,7 @@ def test_kanban_tools_visible_with_toolset_config(monkeypatch, tmp_path):
|
|||
"kanban_show", "kanban_complete", "kanban_block", "kanban_heartbeat",
|
||||
"kanban_comment", "kanban_create", "kanban_link",
|
||||
"kanban_unblock",
|
||||
"kanban_attach", "kanban_attach_url", "kanban_attachments",
|
||||
}
|
||||
assert kanban == expected, f"expected {expected}, got {kanban}"
|
||||
|
||||
|
|
@ -2030,7 +2032,7 @@ def test_board_param_rejects_invalid_slug(multi_board_env):
|
|||
|
||||
|
||||
def test_board_param_in_all_schemas():
|
||||
"""All nine kanban_* tool schemas must expose an optional ``board``
|
||||
"""Every kanban_* tool schema must expose an optional ``board``
|
||||
parameter. This pins the contract surfaced to the LLM — adding a
|
||||
new kanban tool without ``board`` will fail CI immediately."""
|
||||
from tools import kanban_tools as kt
|
||||
|
|
@ -2045,6 +2047,9 @@ def test_board_param_in_all_schemas():
|
|||
kt.KANBAN_CREATE_SCHEMA,
|
||||
kt.KANBAN_UNBLOCK_SCHEMA,
|
||||
kt.KANBAN_LINK_SCHEMA,
|
||||
kt.KANBAN_ATTACH_SCHEMA,
|
||||
kt.KANBAN_ATTACH_URL_SCHEMA,
|
||||
kt.KANBAN_ATTACHMENTS_SCHEMA,
|
||||
]
|
||||
for schema in schemas:
|
||||
props = schema["parameters"]["properties"]
|
||||
|
|
@ -2246,3 +2251,229 @@ def test_maybe_auto_subscribe_swallows_add_notify_sub_failure(monkeypatch, worke
|
|||
d = json.loads(out)
|
||||
assert d["ok"] is True, d
|
||||
assert d["subscribed"] is False, d
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Attachments — kanban_attach / kanban_attach_url / kanban_attachments
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_attach_roundtrips_bytes_to_row_and_disk(worker_env):
|
||||
"""kanban_attach decodes base64, writes the blob, and records the row."""
|
||||
import base64
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_cli import kanban_db as kb
|
||||
from tools import kanban_tools as kt
|
||||
|
||||
content = b"hello attachment from a tool"
|
||||
out = kt._handle_attach({
|
||||
"filename": "notes.txt",
|
||||
"content_base64": base64.b64encode(content).decode(),
|
||||
"content_type": "text/plain",
|
||||
})
|
||||
d = json.loads(out)
|
||||
assert d.get("ok") is True, out
|
||||
assert d["size"] == len(content)
|
||||
att_id = d["attachment_id"]
|
||||
|
||||
conn = kb.connect()
|
||||
try:
|
||||
atts = kb.list_attachments(conn, worker_env)
|
||||
assert [a.filename for a in atts] == ["notes.txt"]
|
||||
a = atts[0]
|
||||
assert a.id == att_id
|
||||
assert a.content_type == "text/plain"
|
||||
assert a.uploaded_by == "agent"
|
||||
# Blob is on disk under the task's attachments dir with the bytes.
|
||||
assert Path(a.stored_path).read_bytes() == content
|
||||
assert Path(a.stored_path).resolve().is_relative_to(
|
||||
kb.task_attachments_dir(worker_env).resolve()
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_attach_rejects_oversize(worker_env, monkeypatch):
|
||||
"""A decoded payload over the cap returns a clean tool error, no row."""
|
||||
import base64
|
||||
|
||||
from hermes_cli import kanban_db as kb
|
||||
from tools import kanban_tools as kt
|
||||
|
||||
# Shrink the cap so we don't have to build a 25 MB payload.
|
||||
monkeypatch.setattr(kb, "_MAX_ATTACHMENT_BYTES", 8)
|
||||
out = kt._handle_attach({
|
||||
"filename": "big.bin",
|
||||
"content_base64": base64.b64encode(b"0123456789").decode(),
|
||||
})
|
||||
d = json.loads(out)
|
||||
assert "error" in d
|
||||
assert "MB limit" in d["error"]
|
||||
|
||||
conn = kb.connect()
|
||||
try:
|
||||
assert kb.list_attachments(conn, worker_env) == []
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_attach_rejects_bad_base64(worker_env):
|
||||
from tools import kanban_tools as kt
|
||||
|
||||
out = kt._handle_attach({"filename": "x.txt", "content_base64": "not base64!!!"})
|
||||
d = json.loads(out)
|
||||
assert "error" in d and "base64" in d["error"]
|
||||
|
||||
|
||||
def test_attach_requires_filename_and_content(worker_env):
|
||||
from tools import kanban_tools as kt
|
||||
|
||||
assert "error" in json.loads(kt._handle_attach({"content_base64": "QQ=="}))
|
||||
assert "error" in json.loads(kt._handle_attach({"filename": "x.txt"}))
|
||||
|
||||
|
||||
def test_attach_enforces_worker_task_ownership(worker_env):
|
||||
"""A worker scoped to its own task can't attach to a foreign task."""
|
||||
import base64
|
||||
|
||||
from hermes_cli import kanban_db as kb
|
||||
from tools import kanban_tools as kt
|
||||
|
||||
conn = kb.connect()
|
||||
try:
|
||||
other = kb.create_task(conn, title="someone else's task", assignee="peer")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
out = kt._handle_attach({
|
||||
"task_id": other,
|
||||
"filename": "x.txt",
|
||||
"content_base64": base64.b64encode(b"x").decode(),
|
||||
})
|
||||
d = json.loads(out)
|
||||
assert "error" in d
|
||||
assert "scoped to task" in d["error"]
|
||||
|
||||
|
||||
def test_attachments_lists_uploaded_files(worker_env):
|
||||
import base64
|
||||
|
||||
from tools import kanban_tools as kt
|
||||
|
||||
kt._handle_attach({
|
||||
"filename": "a.txt",
|
||||
"content_base64": base64.b64encode(b"aaa").decode(),
|
||||
})
|
||||
kt._handle_attach({
|
||||
"filename": "b.txt",
|
||||
"content_base64": base64.b64encode(b"bbbb").decode(),
|
||||
})
|
||||
out = kt._handle_attachments({})
|
||||
d = json.loads(out)
|
||||
assert d.get("ok") is True
|
||||
names = sorted(a["filename"] for a in d["attachments"])
|
||||
assert names == ["a.txt", "b.txt"]
|
||||
sizes = {a["filename"]: a["size"] for a in d["attachments"]}
|
||||
assert sizes == {"a.txt": 3, "b.txt": 4}
|
||||
|
||||
|
||||
def test_attachments_unknown_task_errors(worker_env):
|
||||
from tools import kanban_tools as kt
|
||||
|
||||
out = kt._handle_attachments({"task_id": "t_nope"})
|
||||
assert "error" in json.loads(out)
|
||||
|
||||
|
||||
def test_attach_url_fetches_local_fixture(worker_env):
|
||||
"""kanban_attach_url downloads from an http(s) URL and stores the bytes."""
|
||||
import http.server
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_cli import kanban_db as kb
|
||||
from tools import kanban_tools as kt
|
||||
|
||||
payload = b"downloaded-by-url body"
|
||||
|
||||
class _Handler(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self): # noqa: N802
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/octet-stream")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def log_message(self, *a): # silence
|
||||
pass
|
||||
|
||||
srv = http.server.HTTPServer(("127.0.0.1", 0), _Handler)
|
||||
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
||||
try:
|
||||
port = srv.server_address[1]
|
||||
out = kt._handle_attach_url({
|
||||
"url": f"http://127.0.0.1:{port}/files/report.bin",
|
||||
})
|
||||
finally:
|
||||
srv.shutdown()
|
||||
d = json.loads(out)
|
||||
assert d.get("ok") is True, out
|
||||
assert d["size"] == len(payload)
|
||||
|
||||
conn = kb.connect()
|
||||
try:
|
||||
atts = kb.list_attachments(conn, worker_env)
|
||||
# Filename derived from the URL path leaf.
|
||||
assert atts[0].filename == "report.bin"
|
||||
assert Path(atts[0].stored_path).read_bytes() == payload
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_attach_url_rejects_oversize_stream(worker_env, monkeypatch):
|
||||
"""An oversize response body is rejected during download, no row written."""
|
||||
import http.server
|
||||
import threading
|
||||
|
||||
from hermes_cli import kanban_db as kb
|
||||
from tools import kanban_tools as kt
|
||||
|
||||
big = b"x" * (64 * 1024)
|
||||
|
||||
class _Handler(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self): # noqa: N802
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/octet-stream")
|
||||
self.send_header("Content-Length", str(len(big)))
|
||||
self.end_headers()
|
||||
self.wfile.write(big)
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(kb, "_MAX_ATTACHMENT_BYTES", 1024)
|
||||
srv = http.server.HTTPServer(("127.0.0.1", 0), _Handler)
|
||||
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
||||
try:
|
||||
port = srv.server_address[1]
|
||||
out = kt._handle_attach_url({"url": f"http://127.0.0.1:{port}/big.bin"})
|
||||
finally:
|
||||
srv.shutdown()
|
||||
d = json.loads(out)
|
||||
assert "error" in d
|
||||
assert "MB limit" in d["error"]
|
||||
|
||||
conn = kb.connect()
|
||||
try:
|
||||
assert kb.list_attachments(conn, worker_env) == []
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_attach_url_rejects_non_http_scheme(worker_env):
|
||||
from tools import kanban_tools as kt
|
||||
|
||||
out = kt._handle_attach_url({"url": "file:///etc/passwd"})
|
||||
d = json.loads(out)
|
||||
assert "error" in d
|
||||
assert "scheme" in d["error"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue