feat(files): play video and audio from outside the workspace too

A clip an agent wrote inside the workspace played with a working scrub bar,
while the same file in /tmp was refused as an unsupported type. The workspace
preview classified media with its own inline extension sets and the attachment
allowlist had no media at all, so the two paths disagreed about what a video is.

- VIDEO_ATTACHMENT_EXTENSIONS and AUDIO_ATTACHMENT_EXTENSIONS now live in
  attachment-registry.ts and are imported by file-content's classification, so
  both paths answer the same. mp4/webm/mov/m4v/ogv and
  mp3/wav/ogg/oga/m4a/aac/flac/opus join the attachment allowlist.
- Real MIME types for those extensions. Without one the raw route falls back to
  application/octet-stream, which a <video> refuses to decode: the player
  renders and then does nothing.
- getAttachmentType() gained the video and audio members of
  AttachmentDetectedType. Attachment cards have no per-type CSS and their
  thumbnail falls back to the type label, since the thumbnailer has no media
  branch and answers 204 rather than spawning a converter.
- The preview overlay's by-id branch renders <video>/<audio> with the same
  markup as the workspace branch, playsinline included. Serving was already
  range-aware, so seeking works.

The image-watcher keeps its own narrow detection list (png/pdf/docx/pptx), so
this does not start popping cards for every video an agent writes. Text types
that are not md or txt (.json, .log, code files) remain out of the allowlist by
choice and still report what is previewable instead.

Verified on an isolated instance: an external mp4 and mp3 both play, seek, and
report the right duration, matching the in-workspace clip exactly, and a click
on an external mp4 in the terminal opens the player with no attachment card.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-16 17:35:43 +02:00
parent 4e2c1b9989
commit cbc54fc98d
7 changed files with 151 additions and 6 deletions
+24
View File
@@ -14,6 +14,26 @@ import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from './config/att
import { validateSessionFilePath } from './web/route-helpers.js';
import type { AttachmentDetectedEvent, AttachmentDetectedType } from './types.js';
/**
* Playable media extensions, single-sourced here because the WORKSPACE preview
* (`file-content`'s media classification) and the out-of-workspace attachment
* path must agree on what plays. They diverged once: a video an agent wrote
* inside the workspace played with a working scrub bar, while the same file in
* `/tmp` was refused as an unsupported type, which reads as a bug rather than a
* boundary. Serving is range-aware in both, which is what makes seeking work.
*/
export const VIDEO_ATTACHMENT_EXTENSIONS: ReadonlySet<string> = new Set(['mp4', 'webm', 'mov', 'm4v', 'ogv']);
export const AUDIO_ATTACHMENT_EXTENSIONS: ReadonlySet<string> = new Set([
'mp3',
'wav',
'ogg',
'oga',
'm4a',
'aac',
'flac',
'opus',
]);
const SUPPORTED_ATTACHMENT_EXTENSIONS = new Set([
'png',
'jpg',
@@ -25,6 +45,8 @@ const SUPPORTED_ATTACHMENT_EXTENSIONS = new Set([
'pptx',
'md',
'txt',
...VIDEO_ATTACHMENT_EXTENSIONS,
...AUDIO_ATTACHMENT_EXTENSIONS,
]);
export type AttachmentSource = 'detected' | 'external';
@@ -108,6 +130,8 @@ export function isSupportedAttachmentExtension(extension: string): boolean {
export function getAttachmentType(extension: string): AttachmentDetectedType {
const normalized = extension.toLowerCase().replace(/^\./, '');
if (['png', 'jpg', 'jpeg', 'gif', 'webp'].includes(normalized)) return 'image';
if (VIDEO_ATTACHMENT_EXTENSIONS.has(normalized)) return 'video';
if (AUDIO_ATTACHMENT_EXTENSIONS.has(normalized)) return 'audio';
if (normalized === 'pdf') return 'pdf';
if (normalized === 'pptx') return 'presentation';
if (normalized === 'md') return 'markdown';
+9 -1
View File
@@ -63,7 +63,15 @@ export interface ImageDetectedEvent {
size: number;
}
export type AttachmentDetectedType = 'image' | 'pdf' | 'document' | 'presentation' | 'markdown' | 'text';
export type AttachmentDetectedType =
| 'image'
| 'video'
| 'audio'
| 'pdf'
| 'document'
| 'presentation'
| 'markdown'
| 'text';
/**
* Event emitted when a new previewable attachment file is detected in a session's
+11 -1
View File
@@ -3284,7 +3284,7 @@ Object.assign(CodemanApp.prototype, {
if (/unsupported/i.test(reason)) {
const ext = (filePath.split('.').pop() || '').toLowerCase();
return {
error: `Cannot preview .${ext} from outside the session workspace (images, PDF, Office documents, Markdown and text only).`,
error: `Cannot preview .${ext} from outside the session workspace (images, video, audio, PDF, Office documents, Markdown and text only).`,
};
}
return { error: reason };
@@ -3341,12 +3341,22 @@ Object.assign(CodemanApp.prototype, {
if (attachmentId) {
const base = `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}`;
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg']);
const VIDEO_EXTS = new Set(['mp4', 'webm', 'mov', 'm4v', 'ogv']);
const AUDIO_EXTS = new Set(['mp3', 'wav', 'ogg', 'oga', 'm4a', 'aac', 'flac', 'opus']);
// Size when we just registered the file ourselves, so a path opened from a
// link reads like a workspace preview instead of a bare "PNG". History
// cards arrive with an id and no size and keep the short form.
footerEl.textContent = externalSize ? `${this.formatFileSize(externalSize)} • ${ext}` : ext.toUpperCase();
if (IMAGE_EXTS.has(ext)) {
bodyEl.innerHTML = `<img src="${escapeHtml(`${base}/raw`)}" alt="${escapeHtml(filePath)}">`;
} else if (VIDEO_EXTS.has(ext)) {
// Same markup as the workspace branch below, including playsinline: iOS
// otherwise hijacks playback into its own fullscreen player, which
// leaves this overlay behind it with no way back but its close button.
// The attachment raw route is range-aware, so the scrub bar works.
bodyEl.innerHTML = `<video src="${escapeHtml(`${base}/raw`)}" controls autoplay playsinline preload="metadata"></video>`;
} else if (AUDIO_EXTS.has(ext)) {
bodyEl.innerHTML = `<audio src="${escapeHtml(`${base}/raw`)}" controls autoplay preload="metadata"></audio>`;
} else if (ext === 'pdf') {
bodyEl.innerHTML = `<iframe src="${escapeHtml(`${base}/raw`)}" title="${escapeHtml(filePath)}"></iframe>`;
} else if (ext === 'docx' || ext === 'pptx') {
+22 -2
View File
@@ -23,12 +23,14 @@ import type {
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
import { fileStreamManager } from '../../file-stream-manager.js';
import {
AUDIO_ATTACHMENT_EXTENSIONS,
AttachmentRegistrationError,
attachmentRecordToEvent,
attachmentRegistry,
buildFileThumbnailRoute,
isSupportedAttachmentExtension,
registerExternalAttachment,
VIDEO_ATTACHMENT_EXTENSIONS,
type AttachmentRecord,
} from '../../attachment-registry.js';
import { generateFirstPageThumbnail } from '../../document-thumbnailer.js';
@@ -67,6 +69,22 @@ const MIME_TYPES: Record<string, string> = {
webp: 'image/webp',
ico: 'image/x-icon',
bmp: 'image/bmp',
// Media needs a real type, not the octet-stream fallback: a <video>/<audio>
// element refuses to decode an unknown type, so a missing entry here presents
// as a player that renders and then does nothing.
mp4: 'video/mp4',
webm: 'video/webm',
mov: 'video/quicktime',
m4v: 'video/x-m4v',
ogv: 'video/ogg',
mp3: 'audio/mpeg',
wav: 'audio/wav',
ogg: 'audio/ogg',
oga: 'audio/ogg',
m4a: 'audio/mp4',
aac: 'audio/aac',
flac: 'audio/flac',
opus: 'audio/opus',
pdf: 'application/pdf',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
@@ -1099,8 +1117,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
// so the file viewer can open the same files.
const ext = filePath.split('.').pop()?.toLowerCase() || '';
const imageExts = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico']);
const videoExts = new Set(['mp4', 'webm', 'mov', 'm4v', 'ogv']);
const audioExts = new Set(['mp3', 'wav', 'ogg', 'oga', 'm4a', 'aac', 'flac', 'opus']);
// Shared with the attachment registry so a video plays the same whether it
// sits in the workspace or is reached by id from outside it.
const videoExts = VIDEO_ATTACHMENT_EXTENSIONS;
const audioExts = AUDIO_ATTACHMENT_EXTENSIONS;
const otherBinaryExts = new Set([
'pdf',
'zip',