feat(DIS-154): agent file sending via [ATTACH: path] convention
Some checks failed
CI / build-and-test (ubuntu-latest) (pull_request) Has been cancelled
CI / build-and-test (windows-latest) (pull_request) Has been cancelled
CI / lint (pull_request) Has been cancelled

- parseAttachments() extracts [ATTACH: path] markers from response text
- validateAttachPath() enforces workspace boundary + readability + 8MB limit
- sendResponse() sends validated files as Discord attachments before text
- CLAUDE.md template teaches agents the [ATTACH: ...] syntax
- Path-traversal guard: only files within workspace are allowed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Nick Tabeling 2026-04-13 10:16:50 +02:00
parent 3070406d0f
commit 00f7909e6b
5 changed files with 260 additions and 39 deletions

View file

@ -108,6 +108,25 @@ ${role}
- Be concise in your responses -- they are displayed in Discord (2000 char limit per message) - Be concise in your responses -- they are displayed in Discord (2000 char limit per message)
- If a task is outside your area of expertise, say so clearly - If a task is outside your area of expertise, say so clearly
## Sending Files to Discord
To send a file from your workspace back to the user in Discord, include this marker anywhere in your response:
\`\`\`
[ATTACH: path/to/file.jpg]
\`\`\`
- The path can be relative to your workspace root or absolute
- Only files within your workspace directory are allowed
- Maximum file size: 8 MB
- Multiple files are supported one marker per file
- The marker is removed from the text before display; only the file is sent
Example: If a user asks "send me the image", respond with:
\`[ATTACH: images/result.png]\`
Do **not** use this for files outside your workspace or for sensitive files.
## Workspace Contents ## Workspace Contents
Add project-specific context below. Describe the files, conventions, Add project-specific context below. Describe the files, conventions,

View file

@ -1,3 +1,5 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { TextChannel, Message, AttachmentBuilder } from "discord.js"; import { TextChannel, Message, AttachmentBuilder } from "discord.js";
import { splitForDiscord } from "./split.js"; import { splitForDiscord } from "./split.js";
import { childLogger } from "../runtime/logger.js"; import { childLogger } from "../runtime/logger.js";
@ -5,26 +7,113 @@ import { childLogger } from "../runtime/logger.js";
const log = childLogger("send-response"); const log = childLogger("send-response");
const FILE_THRESHOLD = 4000; const FILE_THRESHOLD = 4000;
const MAX_ATTACH_BYTES = 8 * 1024 * 1024; // 8 MB — Discord limit for regular bots
const ATTACH_PATTERN = /\[ATTACH:\s*([^\]]+)\]/g;
export interface ParsedResponse {
text: string;
attachPaths: string[];
}
/**
* Extracts [ATTACH: path] markers from agent response text.
* Returns cleaned text and a list of resolved absolute paths.
* Paths are resolved relative to workspacePath if not absolute.
*/
export function parseAttachments(raw: string, workspacePath: string): ParsedResponse {
const attachPaths: string[] = [];
const text = raw.replace(ATTACH_PATTERN, (_match, rawPath: string) => {
const trimmed = rawPath.trim();
const resolved = path.isAbsolute(trimmed)
? trimmed
: path.resolve(workspacePath, trimmed);
attachPaths.push(resolved);
return "";
}).trim();
return { text, attachPaths };
}
/**
* Validates an attach path: must exist, be readable, be within workspacePath,
* and be no larger than MAX_ATTACH_BYTES.
* Returns an error string if invalid, null if OK.
*/
export function validateAttachPath(filePath: string, workspacePath: string): string | null {
const workspaceRoot = path.resolve(workspacePath);
const resolved = path.resolve(filePath);
// Path-traversal guard
if (!resolved.startsWith(workspaceRoot + path.sep) && resolved !== workspaceRoot) {
return `Datei liegt außerhalb des Workspace: \`${path.basename(resolved)}\``;
}
// Existence + read permission
try {
fs.accessSync(resolved, fs.constants.R_OK);
} catch {
return `Datei nicht lesbar oder nicht vorhanden: \`${path.basename(resolved)}\``;
}
// Size check
const stat = fs.statSync(resolved);
if (stat.size > MAX_ATTACH_BYTES) {
const mb = (stat.size / (1024 * 1024)).toFixed(1);
return `Datei zu groß (${mb} MB, Maximum 8 MB): \`${path.basename(resolved)}\``;
}
return null;
}
/** /**
* Sends an agent response to a Discord channel. * Sends an agent response to a Discord channel.
* - Text > 4000 chars is sent as a Markdown file attachment. * - Parses [ATTACH: path] markers and sends valid files as attachments.
* - Text > 4000 chars (after stripping markers) is sent as a Markdown file attachment.
* - Text <= 4000 chars is split with splitForDiscord and sent as plain messages. * - Text <= 4000 chars is split with splitForDiscord and sent as plain messages.
*/ */
export async function sendResponse( export async function sendResponse(
channel: TextChannel, channel: TextChannel,
text: string, text: string,
_triggerMessage: Message _triggerMessage: Message,
workspacePath?: string
): Promise<void> { ): Promise<void> {
if (text.length === 0) { if (text.length === 0) {
return; return;
} }
if (text.length > FILE_THRESHOLD) { // Parse [ATTACH: ...] markers if we have a workspace context
log.debug({ length: text.length }, "Response exceeds threshold — sending as file"); let cleanText = text;
let attachPaths: string[] = [];
if (workspacePath) {
const parsed = parseAttachments(text, workspacePath);
cleanText = parsed.text;
attachPaths = parsed.attachPaths;
}
// Send file attachments
for (const filePath of attachPaths) {
const err = validateAttachPath(filePath, workspacePath!);
if (err) {
log.warn({ filePath }, "Attachment rejected: " + err);
await channel.send(`⚠️ ${err}`).catch(() => {});
continue;
}
log.debug({ filePath }, "Sending file attachment");
await channel.send({
files: [new AttachmentBuilder(filePath, { name: path.basename(filePath) })],
});
}
// Send text portion
if (cleanText.length === 0) {
return;
}
if (cleanText.length > FILE_THRESHOLD) {
log.debug({ length: cleanText.length }, "Response exceeds threshold — sending as file");
await channel.send({ await channel.send({
files: [ files: [
new AttachmentBuilder(Buffer.from(text, "utf-8"), { new AttachmentBuilder(Buffer.from(cleanText, "utf-8"), {
name: "response.md", name: "response.md",
}), }),
], ],
@ -32,7 +121,7 @@ export async function sendResponse(
return; return;
} }
const chunks = splitForDiscord(text); const chunks = splitForDiscord(cleanText);
for (const chunk of chunks) { for (const chunk of chunks) {
await channel.send(chunk); await channel.send(chunk);
} }

View file

@ -108,7 +108,7 @@ export async function routeMessage(
// Temporarily patch channel.send for sendResponse // Temporarily patch channel.send for sendResponse
(channel as unknown as { send: typeof patchedSend }).send = patchedSend; (channel as unknown as { send: typeof patchedSend }).send = patchedSend;
try { try {
await sendResponse(channel, sanitizedResponse, message); await sendResponse(channel, sanitizedResponse, message, workspace.workspace_path);
} finally { } finally {
(channel as unknown as { send: typeof originalSend }).send = originalSend; (channel as unknown as { send: typeof originalSend }).send = originalSend;
} }

View file

@ -70,6 +70,30 @@ Aktuell senden Agents nur Plaintext. Discord bietet viel mehr:
## Abhängigkeiten ## Abhängigkeiten
Phase 1 abgeschlossen (splitForDiscord, RunResult). Phase 1 abgeschlossen (splitForDiscord, RunResult).
### 6. Agent sendet Dateien zurück (`src/discord/send-response.ts`)
- Agent schreibt `[ATTACH: pfad/zur/datei.ext]` in seine Antwort
- Bot parst alle `[ATTACH: ...]`-Marker heraus, entfernt sie aus dem Text
- Sicherheitscheck vor dem Senden:
- Datei muss existieren und lesbar sein (`fs.accessSync(path, fs.constants.R_OK)`)
- Pfad darf nicht außerhalb des Workspace liegen (Path-Traversal-Schutz via `path.resolve`)
- Max. Dateigröße: **8 MB** (Discord-Limit für reguläre Bots)
- Erlaubte Pfade: relativ zum Workspace-Root oder absolut innerhalb des Workspace
- Dateien werden als Discord-Attachments gesendet, verbleibender Text normal
- CLAUDE.md-Template informiert den Agent über die `[ATTACH: ...]`-Konvention
## Definition of Done
- [ ] Reactions `👀` / `✅` / `❌` werden korrekt gesetzt
- [ ] Typing-Intervall 8s, endet mit Agent-Antwort
- [ ] Antworten > 4000 Zeichen kommen als `.md`-Attachment
- [ ] Eingehende Dateien landen im `.disclaw-inbox/`-Verzeichnis
- [ ] Input > 4000 Zeichen → `⚠️` + Hinweis, kein Agent-Aufruf
- [ ] Agent kann mit `[ATTACH: pfad]` Dateien zurücksenden
- [ ] Path-Traversal-Schutz: nur Dateien im Workspace erlaubt
- [ ] Dateien > 8 MB → Fehlermeldung an Channel statt Absturz
- [ ] CLAUDE.md-Template enthält Hinweis auf `[ATTACH: ...]`
- [ ] Unit-Tests: `send-response`, `attachment-inbox`
- [ ] `npm run build && npm test` grün
## Hinweis ## Hinweis
Reactions, Embeds, Threads und Polls (voller SF-004-Scope) können in Folge-Issues Reactions, Embeds, Threads und Polls (voller SF-004-Scope) können in Folge-Issues
ausgebaut werden. Dieses Issue implementiert den Kern-QoL-Teil. ausgebaut werden. Dieses Issue implementiert den Kern-QoL-Teil.

View file

@ -1,8 +1,10 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import * as fs from "node:fs";
import { sendResponse } from "../../src/discord/send-response"; import * as os from "node:os";
import * as path from "node:path";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { sendResponse, parseAttachments, validateAttachPath } from "../../src/discord/send-response";
import type { TextChannel, Message } from "discord.js"; import type { TextChannel, Message } from "discord.js";
// Minimal stubs
function makeChannel() { function makeChannel() {
return { return {
send: vi.fn().mockResolvedValue({ id: "msg-1" }), send: vi.fn().mockResolvedValue({ id: "msg-1" }),
@ -13,6 +15,69 @@ function makeMessage() {
return {} as Message; return {} as Message;
} }
describe("parseAttachments", () => {
it("returns empty attachPaths and original text when no markers", () => {
const result = parseAttachments("Hello world", "/workspace");
expect(result.text).toBe("Hello world");
expect(result.attachPaths).toHaveLength(0);
});
it("extracts a single relative [ATTACH: ...] marker", () => {
const result = parseAttachments("Here is the file [ATTACH: images/cat.jpg]", "/workspace");
expect(result.text).toBe("Here is the file");
expect(result.attachPaths).toHaveLength(1);
expect(result.attachPaths[0]).toBe(path.resolve("/workspace", "images/cat.jpg"));
});
it("extracts an absolute [ATTACH: ...] marker unchanged", () => {
const abs = path.resolve("/workspace/file.txt");
const result = parseAttachments(`See [ATTACH: ${abs}]`, "/workspace");
expect(result.attachPaths[0]).toBe(abs);
});
it("extracts multiple markers", () => {
const result = parseAttachments(
"Files: [ATTACH: a.txt] and [ATTACH: b.png]",
"/workspace"
);
expect(result.attachPaths).toHaveLength(2);
expect(result.text).toBe("Files: and");
});
});
describe("validateAttachPath", () => {
let tmpDir: string;
let tmpFile: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "disclaw-test-"));
tmpFile = path.join(tmpDir, "file.txt");
fs.writeFileSync(tmpFile, "hello");
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it("returns null for a valid readable file within workspace", () => {
expect(validateAttachPath(tmpFile, tmpDir)).toBeNull();
});
it("rejects paths outside workspace (path traversal)", () => {
const outsideFile = path.join(os.tmpdir(), "outside.txt");
fs.writeFileSync(outsideFile, "secret");
const err = validateAttachPath(outsideFile, tmpDir);
expect(err).toMatch(/außerhalb/);
fs.unlinkSync(outsideFile);
});
it("rejects non-existent files", () => {
const missing = path.join(tmpDir, "ghost.txt");
const err = validateAttachPath(missing, tmpDir);
expect(err).toMatch(/nicht lesbar/);
});
});
describe("sendResponse", () => { describe("sendResponse", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@ -20,45 +85,18 @@ describe("sendResponse", () => {
it("sends chunks via channel.send() for text <= 4000 chars", async () => { it("sends chunks via channel.send() for text <= 4000 chars", async () => {
const channel = makeChannel(); const channel = makeChannel();
const text = "Hello, world!"; await sendResponse(channel, "Hello, world!", makeMessage());
await sendResponse(channel, text, makeMessage());
expect(channel.send).toHaveBeenCalledTimes(1); expect(channel.send).toHaveBeenCalledTimes(1);
expect(channel.send).toHaveBeenCalledWith(text);
// No file attachment
const call = vi.mocked(channel.send).mock.calls[0][0]; const call = vi.mocked(channel.send).mock.calls[0][0];
expect(typeof call).toBe("string"); expect(typeof call).toBe("string");
}); });
it("sends multiple chunks for text <= 4000 chars that needs splitting", async () => {
const channel = makeChannel();
// Build a text that exceeds the 1900 default split limit but stays <= 4000
const line = "a".repeat(100);
const text = Array.from({ length: 30 }, () => line).join("\n");
// 30*100 + 29 newlines = 3029 chars — within 4000 but split into chunks
expect(text.length).toBeLessThanOrEqual(4000);
await sendResponse(channel, text, makeMessage());
expect(channel.send).toHaveBeenCalledTimes(
(channel.send as ReturnType<typeof vi.fn>).mock.calls.length
);
// All calls must be strings, not objects with files
for (const [arg] of vi.mocked(channel.send).mock.calls) {
expect(typeof arg).toBe("string");
}
});
it("sends an attachment for text > 4000 chars", async () => { it("sends an attachment for text > 4000 chars", async () => {
const channel = makeChannel(); const channel = makeChannel();
const text = "x".repeat(4001); await sendResponse(channel, "x".repeat(4001), makeMessage());
await sendResponse(channel, text, makeMessage());
expect(channel.send).toHaveBeenCalledTimes(1); expect(channel.send).toHaveBeenCalledTimes(1);
const call = vi.mocked(channel.send).mock.calls[0][0]; const call = vi.mocked(channel.send).mock.calls[0][0];
expect(typeof call).toBe("object"); expect(typeof call).toBe("object");
expect((call as { files: unknown[] }).files).toBeDefined();
expect((call as { files: unknown[] }).files).toHaveLength(1); expect((call as { files: unknown[] }).files).toHaveLength(1);
}); });
@ -67,4 +105,55 @@ describe("sendResponse", () => {
await sendResponse(channel, "", makeMessage()); await sendResponse(channel, "", makeMessage());
expect(channel.send).not.toHaveBeenCalled(); expect(channel.send).not.toHaveBeenCalled();
}); });
it("sends [ATTACH: ...] file as Discord attachment", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "disclaw-attach-"));
const filePath = path.join(tmpDir, "result.txt");
fs.writeFileSync(filePath, "data");
try {
const channel = makeChannel();
await sendResponse(channel, `Here it is [ATTACH: result.txt]`, makeMessage(), tmpDir);
// One call for the file, one for the text
expect(channel.send).toHaveBeenCalledTimes(2);
const fileCall = vi.mocked(channel.send).mock.calls[0][0] as { files: unknown[] };
expect(fileCall.files).toHaveLength(1);
const textCall = vi.mocked(channel.send).mock.calls[1][0];
expect(typeof textCall).toBe("string");
expect(textCall as string).toContain("Here it is");
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("sends warning message for file outside workspace", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "disclaw-ws-"));
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "disclaw-out-"));
const outsideFile = path.join(outsideDir, "secret.txt");
fs.writeFileSync(outsideFile, "secret");
try {
const channel = makeChannel();
await sendResponse(channel, `[ATTACH: ${outsideFile}]`, makeMessage(), tmpDir);
expect(channel.send).toHaveBeenCalledTimes(1);
const call = vi.mocked(channel.send).mock.calls[0][0] as string;
expect(call).toMatch(/⚠️/);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
fs.rmSync(outsideDir, { recursive: true, force: true });
}
});
it("only sends text when no [ATTACH: ...] present and workspacePath given", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "disclaw-plain-"));
try {
const channel = makeChannel();
await sendResponse(channel, "Plain text response", makeMessage(), tmpDir);
expect(channel.send).toHaveBeenCalledTimes(1);
expect(vi.mocked(channel.send).mock.calls[0][0]).toBe("Plain text response");
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
}); });