- 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>
159 lines
5.7 KiB
TypeScript
159 lines
5.7 KiB
TypeScript
import * as fs from "node:fs";
|
|
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";
|
|
|
|
function makeChannel() {
|
|
return {
|
|
send: vi.fn().mockResolvedValue({ id: "msg-1" }),
|
|
} as unknown as TextChannel;
|
|
}
|
|
|
|
function makeMessage() {
|
|
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", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("sends chunks via channel.send() for text <= 4000 chars", async () => {
|
|
const channel = makeChannel();
|
|
await sendResponse(channel, "Hello, world!", makeMessage());
|
|
expect(channel.send).toHaveBeenCalledTimes(1);
|
|
const call = vi.mocked(channel.send).mock.calls[0][0];
|
|
expect(typeof call).toBe("string");
|
|
});
|
|
|
|
it("sends an attachment for text > 4000 chars", async () => {
|
|
const channel = makeChannel();
|
|
await sendResponse(channel, "x".repeat(4001), makeMessage());
|
|
expect(channel.send).toHaveBeenCalledTimes(1);
|
|
const call = vi.mocked(channel.send).mock.calls[0][0];
|
|
expect(typeof call).toBe("object");
|
|
expect((call as { files: unknown[] }).files).toHaveLength(1);
|
|
});
|
|
|
|
it("does not call channel.send() for empty text", async () => {
|
|
const channel = makeChannel();
|
|
await sendResponse(channel, "", makeMessage());
|
|
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 });
|
|
}
|
|
});
|
|
});
|