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>
135 lines
3.8 KiB
Go
135 lines
3.8 KiB
Go
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)
|
|
}
|
|
}
|