mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 00:49:41 +02:00
COD-37 add server-side attachment pipeline (registry, magic-link, path guard)
Adds the foundation for serving local files to the browser as live external attachments with a stable id, so requests never carry arbitrary absolute paths. - attachment-registry: in-memory, session-scoped registry. registerExternalAttachment validates an absolute path, resolves symlinks, enforces the path guard, and mints an `att_<uuid>` id; records are cleared when the session is removed. - attachment path guard: a configurable blocklist (secret locations + /root,/etc trees, extendable via attachmentBlockedPaths / CODEMAN_ATTACHMENT_BLOCKED_PATHS) plus an optional, default-off workspace-confinement mode. Shares one sensitive-path blocklist (web/sensitive-path.ts) with /api/download, which is refactored to use the extracted module instead of an inline copy. - terminal magic links: the session scans output for codeman://attach?path=... and emits `attachmentRequested`; the web server registers the file and broadcasts an `attachment:detected` SSE event. `codeman attach <path>` (CLI) prints the magic link or POSTs directly when a session id is known. - image watcher: detects png/pdf/docx/pptx dropped into a session's working dir and emits `attachment:detected`. - routes: POST /api/sessions/:id/attachments (register) and GET /api/sessions/:id/attachments/:attachmentId/raw (serve), both re-checking the guard before streaming. Document previews/thumbnails and the attachment-history drawer build on this foundation and land separately. Verified: tsc --noEmit, lint, format, frontend-syntax, full test:ci (2846 passed), and a server boot smoke (/api/status 200).
This commit is contained in:
+45
-12
@@ -145,9 +145,9 @@ describe('ImageWatcher', () => {
|
||||
// ========== Image Detection ==========
|
||||
|
||||
describe('image detection', () => {
|
||||
it('should emit image:detected for .png files', () => {
|
||||
it('should emit attachment:detected for .png files', () => {
|
||||
const handler = vi.fn();
|
||||
watcher.on('image:detected', handler);
|
||||
watcher.on('attachment:detected', handler);
|
||||
|
||||
watcher.watchSession('session-1', '/home/user/project');
|
||||
const chokidarWatcher = mockWatchers.get('/home/user/project')!;
|
||||
@@ -161,9 +161,42 @@ describe('ImageWatcher', () => {
|
||||
expect(event.fileName).toBe('screenshot.png');
|
||||
expect(event.filePath).toBe('/home/user/project/screenshot.png');
|
||||
expect(event.relativePath).toBe('screenshot.png');
|
||||
expect(event.extension).toBe('png');
|
||||
expect(event.attachmentType).toBe('image');
|
||||
expect(event.size).toBe(2048);
|
||||
});
|
||||
|
||||
it('should not emit legacy image:detected for .png attachment cards', () => {
|
||||
const handler = vi.fn();
|
||||
watcher.on('image:detected', handler);
|
||||
|
||||
watcher.watchSession('session-1', '/home/user/project');
|
||||
mockWatchers.get('/home/user/project')!.emit('add', '/home/user/project/screenshot.png');
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['report.pdf', 'pdf'],
|
||||
['brief.docx', 'document'],
|
||||
['deck.pptx', 'presentation'],
|
||||
])('should emit attachment:detected for %s files', (fileName, attachmentType) => {
|
||||
const handler = vi.fn();
|
||||
watcher.on('attachment:detected', handler);
|
||||
|
||||
watcher.watchSession('session-1', '/home/user/project');
|
||||
mockWatchers.get('/home/user/project')!.emit('add', `/home/user/project/${fileName}`);
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler.mock.calls[0][0]).toMatchObject({
|
||||
sessionId: 'session-1',
|
||||
fileName,
|
||||
attachmentType,
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit for .jpg files', () => {
|
||||
const handler = vi.fn();
|
||||
watcher.on('image:detected', handler);
|
||||
@@ -250,10 +283,10 @@ describe('ImageWatcher', () => {
|
||||
watcher.on('image:detected', handler);
|
||||
|
||||
watcher.watchSession('session-1', '/home/user/project');
|
||||
mockWatchers.get('/home/user/project')!.emit('add', '/home/user/project/assets/img.png');
|
||||
mockWatchers.get('/home/user/project')!.emit('add', '/home/user/project/assets/img.jpg');
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
expect(handler.mock.calls[0][0].relativePath).toBe('assets/img.png');
|
||||
expect(handler.mock.calls[0][0].relativePath).toBe('assets/img.jpg');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -268,11 +301,11 @@ describe('ImageWatcher', () => {
|
||||
const chokidarWatcher = mockWatchers.get('/home/user/project')!;
|
||||
|
||||
// Rapid adds of the same file
|
||||
chokidarWatcher.emit('add', '/home/user/project/screenshot.png');
|
||||
chokidarWatcher.emit('add', '/home/user/project/screenshot.jpg');
|
||||
vi.advanceTimersByTime(100); // not yet past debounce
|
||||
chokidarWatcher.emit('add', '/home/user/project/screenshot.png');
|
||||
chokidarWatcher.emit('add', '/home/user/project/screenshot.jpg');
|
||||
vi.advanceTimersByTime(100);
|
||||
chokidarWatcher.emit('add', '/home/user/project/screenshot.png');
|
||||
chokidarWatcher.emit('add', '/home/user/project/screenshot.jpg');
|
||||
vi.advanceTimersByTime(300); // now past debounce from last emit
|
||||
|
||||
// Should only emit once (the last debounced one)
|
||||
@@ -286,8 +319,8 @@ describe('ImageWatcher', () => {
|
||||
watcher.watchSession('session-1', '/home/user/project');
|
||||
const chokidarWatcher = mockWatchers.get('/home/user/project')!;
|
||||
|
||||
chokidarWatcher.emit('add', '/home/user/project/a.png');
|
||||
chokidarWatcher.emit('add', '/home/user/project/b.png');
|
||||
chokidarWatcher.emit('add', '/home/user/project/a.jpg');
|
||||
chokidarWatcher.emit('add', '/home/user/project/b.jpg');
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(2);
|
||||
@@ -306,7 +339,7 @@ describe('ImageWatcher', () => {
|
||||
|
||||
// Emit 25 unique images in quick succession
|
||||
for (let i = 0; i < 25; i++) {
|
||||
chokidarWatcher.emit('add', `/home/user/project/img${i}.png`);
|
||||
chokidarWatcher.emit('add', `/home/user/project/img${i}.jpg`);
|
||||
vi.advanceTimersByTime(250); // past debounce, within burst window
|
||||
}
|
||||
|
||||
@@ -323,7 +356,7 @@ describe('ImageWatcher', () => {
|
||||
|
||||
// Fill up burst limit
|
||||
for (let i = 0; i < 20; i++) {
|
||||
chokidarWatcher.emit('add', `/home/user/project/img${i}.png`);
|
||||
chokidarWatcher.emit('add', `/home/user/project/img${i}.jpg`);
|
||||
vi.advanceTimersByTime(250);
|
||||
}
|
||||
expect(handler).toHaveBeenCalledTimes(20);
|
||||
@@ -332,7 +365,7 @@ describe('ImageWatcher', () => {
|
||||
vi.advanceTimersByTime(11_000);
|
||||
|
||||
// Should accept new images
|
||||
chokidarWatcher.emit('add', '/home/user/project/new.png');
|
||||
chokidarWatcher.emit('add', '/home/user/project/new.jpg');
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(21);
|
||||
|
||||
Reference in New Issue
Block a user