feat: Phase F-6 — attachments + final docs (F-8 = no-op)
F-6 attachments:
- internal/attachments/ (~145 LOC + 8 tests): per-message Downloader
that fetches Fabric attachment URLs into $TMPDIR/plexum-fabric/<msg-id>/
with the agent's guild token, sanitizing filenames + message ids
against path-traversal
- AppendFooter renders downloaded paths as a markdown footer
("Attachments:\n - /tmp/... (mime, N bytes)\n")
- Agents access via the exec MCP tool (cat / file / etc.)
internal/inbound.Supervisor:
- new Attachments *attachments.Downloader field (nil → skip with warn)
- inbound.dispatch: when message has attachments, blocking-download +
AppendFooter before emitting notification (so agent's first turn
sees the paths)
cmd/plexum-fabric-channel-plugin:
- sup.Attachments = attachments.New("") wired at init
F-8 coalesce: no-op. openclaw plugin's coalesce buffered the
text→thinking→tool→text segments openclaw emits across multiple
deliver() calls per turn. Plexum's loop.Run returns ONE final assistant
text per turn (via extractFinalText), so coalescing isn't a concern.
The channel outbound posts a single message naturally.
F-5b presence-sync: deferred. The openclaw plugin pushes HarborForge
on-call status to Fabric's per-recipient presence so the backend can
busy-discard 'announce' deliveries. Plexum's state machine has
different semantics (idle/working/busy/offline) and is per-Plexum-agent
not per-user; mapping requires more design.
README updated with the phase status table + 16-tool list.
Tests: 8 new in internal/attachments (35 total in this repo).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
134
internal/attachments/attachments_test.go
Normal file
134
internal/attachments/attachments_test.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package attachments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSanitizeFilename(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"proposal.md": "proposal.md",
|
||||
"../etc/passwd": "passwd",
|
||||
"": "attachment.bin",
|
||||
"...": "attachment.bin",
|
||||
"a?b=c": "a",
|
||||
"weird name.png": "weird name.png",
|
||||
"name%20encoded.txt": "name encoded.txt",
|
||||
}
|
||||
for in, want := range cases {
|
||||
got := sanitizeFilename(in)
|
||||
if got != want {
|
||||
t.Errorf("sanitizeFilename(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeID(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"m-abc-123": "m-abc-123",
|
||||
"../../etc/passwd": "______etc_passwd",
|
||||
"": "msg",
|
||||
"with space": "with_space",
|
||||
}
|
||||
for in, want := range cases {
|
||||
got := sanitizeID(in)
|
||||
if got != want {
|
||||
t.Errorf("sanitizeID(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendFooterEmpty(t *testing.T) {
|
||||
got := AppendFooter("hello", nil)
|
||||
if got != "hello" {
|
||||
t.Errorf("empty list should preserve body, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendFooterFormats(t *testing.T) {
|
||||
files := []DownloadResult{
|
||||
{LocalPath: "/tmp/a.md", Name: "a.md", MimeType: "text/markdown", Size: 42},
|
||||
{LocalPath: "/tmp/b.png", Name: "b.png", MimeType: "", Size: 1024},
|
||||
}
|
||||
out := AppendFooter("hello\nworld", files)
|
||||
if !strings.Contains(out, "hello\nworld\n\nAttachments:\n") {
|
||||
t.Errorf("footer placement wrong: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "- /tmp/a.md (text/markdown, 42 bytes)") {
|
||||
t.Errorf("first entry: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "- /tmp/b.png (application/octet-stream, 1024 bytes)") {
|
||||
t.Errorf("default mime: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchAllHappyPath(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte("file contents " + r.URL.Path))
|
||||
}))
|
||||
defer srv.Close()
|
||||
d := New(filepath.Join(t.TempDir(), "att"))
|
||||
files := d.FetchAll(context.Background(), "tok", "msg-1", []AttachmentRef{
|
||||
{URL: srv.URL + "/foo.md", Name: "foo.md", MimeType: "text/markdown"},
|
||||
{URL: srv.URL + "/bar.png", Name: "bar.png"},
|
||||
})
|
||||
if len(files) != 2 {
|
||||
t.Fatalf("len = %d", len(files))
|
||||
}
|
||||
if !strings.HasSuffix(files[0].LocalPath, "/msg-1/foo.md") {
|
||||
t.Errorf("path = %s", files[0].LocalPath)
|
||||
}
|
||||
raw, _ := os.ReadFile(files[0].LocalPath)
|
||||
if !strings.HasPrefix(string(raw), "file contents") {
|
||||
t.Errorf("file content = %q", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchAllSkipsErrors(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, "ok") {
|
||||
w.Write([]byte("ok"))
|
||||
} else {
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
d := New(filepath.Join(t.TempDir(), "att"))
|
||||
files := d.FetchAll(context.Background(), "", "m", []AttachmentRef{
|
||||
{URL: srv.URL + "/ok.txt", Name: "ok.txt"},
|
||||
{URL: srv.URL + "/missing.txt", Name: "missing.txt"},
|
||||
})
|
||||
if len(files) != 1 {
|
||||
t.Errorf("expected 1 result (404 skipped), got %d", len(files))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchAllEmptyListNoMkdir(t *testing.T) {
|
||||
d := New(filepath.Join(t.TempDir(), "att"))
|
||||
got := d.FetchAll(context.Background(), "", "m", nil)
|
||||
if got != nil {
|
||||
t.Errorf("empty → nil, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchAllSendsAuthHeader(t *testing.T) {
|
||||
gotAuth := ""
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("authorization")
|
||||
w.Write([]byte("x"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
d := New(filepath.Join(t.TempDir(), "att"))
|
||||
d.FetchAll(context.Background(), "the-token", "m", []AttachmentRef{
|
||||
{URL: srv.URL + "/a", Name: "a"},
|
||||
})
|
||||
if gotAuth != "Bearer the-token" {
|
||||
t.Errorf("auth header = %q", gotAuth)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user