Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/src/electron/ElectronProtocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ describe("ElectronProtocol", () => {
"http:",
"https:",
]);
assert.deepEqual(directives["media-src"], ["'self'", "t3code:", "blob:"]);
assert.deepEqual(directives["font-src"], ["'self'", "t3code:", "data:"]);
});
});
1 change: 1 addition & 0 deletions apps/desktop/src/electron/ElectronProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrat
`script-src ${scriptSources.join(" ")}`,
`connect-src ${connectSources.join(" ")}`,
`img-src 'self' ${input.scheme}: blob: data: http: https:`,
`media-src 'self' ${input.scheme}: blob:`,
"style-src 'self' 'unsafe-inline'",
`font-src 'self' ${input.scheme}: data:`,
"worker-src 'self' blob:",
Expand Down
31 changes: 31 additions & 0 deletions apps/server/src/assets/AssetAccess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,37 @@ describe("AssetAccess", () => {
}).pipe(Effect.provide(testLayer)),
);

it.effect("serves video attachments inline", () =>
Effect.gen(function* () {
const config = yield* ServerConfig.ServerConfig;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const attachmentId = "thread-1-00000000-0000-4000-8000-000000000001-mp4";
const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.mp4`);
yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true });
yield* fileSystem.writeFile(attachmentPath, new Uint8Array([1, 2, 3]));

const result = yield* issueAssetUrl({
resource: {
_tag: "attachment",
attachmentId,
fileName: "demo.mp4",
mimeType: 'video/mp4; codecs="avc1.42E01E"',
},
});
const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
const separatorIndex = suffix.indexOf("/");

expect(
yield* resolveAsset(suffix.slice(0, separatorIndex), suffix.slice(separatorIndex + 1)),
).toEqual({
kind: "file",
path: attachmentPath,
fileName: "demo.mp4",
mimeType: "video/mp4",
});
}).pipe(Effect.provide(testLayer)),
);
it.effect("issues project favicon capabilities with a signed fallback", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand Down
13 changes: 9 additions & 4 deletions apps/server/src/assets/AssetAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const SIGNING_SECRET_NAME = "asset-access-signing-key";
const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000;
const PROJECT_FAVICON_TOKEN_BUCKET_MS = 30 * 60 * 1000;
const PROJECT_FAVICON_VERSION_PREFIX = "v";
const INLINE_VIDEO_MIME_TYPE_PATTERN = /^video\/[\w!#$&^.+-]+$/i;
const PREVIEW_ASSET_EXTENSIONS = new Set([
...WORKSPACE_BROWSER_PREVIEW_EXTENSIONS,
...WORKSPACE_IMAGE_PREVIEW_EXTENSIONS,
Expand Down Expand Up @@ -300,16 +301,20 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
});
}
// Generic files carry their extension inside the attachment id (that
// shape resolves the on-disk path); images do not. Only generic files
// download, images render inline in chat.
// shape resolves the on-disk path); images do not. Videos and images
// render inline; other generic files download.
const isGenericFile = parseAttachmentFileExtension(input.resource.attachmentId) !== null;
const videoMimeType = input.resource.mimeType?.split(";", 1)[0]?.trim() ?? "";
const isVideo = INLINE_VIDEO_MIME_TYPE_PATTERN.test(videoMimeType);
claims = {
version: 1,
kind: "attachment",
attachmentId: input.resource.attachmentId,
...(isGenericFile ? { download: true } : {}),
...(isGenericFile && !isVideo ? { download: true } : {}),
...(input.resource.fileName !== undefined ? { fileName: input.resource.fileName } : {}),
...(input.resource.mimeType !== undefined ? { mimeType: input.resource.mimeType } : {}),
...(input.resource.mimeType !== undefined
? { mimeType: isVideo ? videoMimeType : input.resource.mimeType }
: {}),
expiresAt,
};
fileName = input.resource.fileName ?? path.basename(attachmentPath);
Expand Down
11 changes: 11 additions & 0 deletions apps/server/src/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,17 @@ describe("assetResponseHeaders", () => {
});
});

it("serves inline videos with their declared mime type", () => {
expect(
assetResponseHeaders("/attachments/demo.bin", {
mimeType: 'video/mp4; codecs="avc1.42E01E"',
}),
).toEqual({
"Cache-Control": "private, max-age=3600",
"Content-Type": "video/mp4",
"X-Content-Type-Options": "nosniff",
});
});
it("declares utf-8 for HTML assets so non-ASCII content renders correctly", () => {
expect(assetResponseHeaders("/workspace/page.html")).toHaveProperty(
"Content-Type",
Expand Down
15 changes: 10 additions & 5 deletions apps/server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ const DOWNLOAD_MIME_TYPE_PATTERN = /^[\w!#$&^.+-]+\/[\w!#$&^.+-]+$/;
const isSafeDownloadMimeType = (mimeType: string): boolean =>
DOWNLOAD_MIME_TYPE_PATTERN.test(mimeType) &&
!/(?:^text\/html$|\/xml(?:$|-)|\+xml$)/i.test(mimeType.trim().toLowerCase());
const isSafeInlineVideoMimeType = (mimeType: string): boolean =>
DOWNLOAD_MIME_TYPE_PATTERN.test(mimeType) && mimeType.toLowerCase().startsWith("video/");

/** RFC 6266 disposition with an ASCII fallback name plus a UTF-8 `filename*`. */
export function downloadContentDisposition(fileName?: string): string {
Expand Down Expand Up @@ -86,6 +88,7 @@ export function assetResponseHeaders(
},
): Record<string, string> {
const lowerPath = filePath.toLowerCase();
const inlineVideoMimeType = options?.mimeType?.split(";", 1)[0]?.trim();
return {
"Cache-Control": "private, max-age=3600",
"X-Content-Type-Options": "nosniff",
Expand All @@ -98,9 +101,11 @@ export function assetResponseHeaders(
? options.mimeType
: "application/octet-stream",
}
: lowerPath.endsWith(".html") || lowerPath.endsWith(".htm")
? { "Content-Type": "text/html; charset=utf-8" }
: {}),
: inlineVideoMimeType !== undefined && isSafeInlineVideoMimeType(inlineVideoMimeType)
? { "Content-Type": inlineVideoMimeType }
: lowerPath.endsWith(".html") || lowerPath.endsWith(".htm")
? { "Content-Type": "text/html; charset=utf-8" }
: {}),
...(!options?.download && lowerPath.endsWith(".svg")
? { "Content-Security-Policy": SVG_CONTENT_SECURITY_POLICY }
: {}),
Expand Down Expand Up @@ -276,9 +281,9 @@ export const assetRouteLayer = HttpRouter.add(
status: 200,
headers: assetResponseHeaders(
asset.path,
asset.download
asset.download || asset.mimeType !== undefined
? {
download: true,
...(asset.download ? { download: true } : {}),
...(asset.fileName !== undefined ? { fileName: asset.fileName } : {}),
...(asset.mimeType !== undefined ? { mimeType: asset.mimeType } : {}),
}
Expand Down
18 changes: 18 additions & 0 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import {
dismissBranchMismatchForSession,
ENVIRONMENT_RECONNECT_WARNING_GRACE_MS,
getStartedThreadModelChangeBlockReason,
loadDesktopVideoPreviewUrl,
isVideoPreviewRequestCurrent,
hasEnvironmentReconnectWarningGraceElapsed,
hasServerAcknowledgedLocalDispatch,
isBranchMismatchDismissedForSession,
Expand All @@ -41,6 +43,22 @@ import {
shouldWriteThreadErrorToCurrentServerThread,
} from "./ChatView.logic";

describe("loadDesktopVideoPreviewUrl", () => {
it("loads video bytes into an object URL", async () => {
const objectUrl = await loadDesktopVideoPreviewUrl("data:video/mp4;base64,AA==");
expect(objectUrl).toMatch(/^blob:/);
URL.revokeObjectURL(objectUrl);
});
});

describe("isVideoPreviewRequestCurrent", () => {
it("rejects changed threads and replaced previews", () => {
expect(isVideoPreviewRequestCurrent("thread-1", "thread-2", 1, 1)).toBe(false);
expect(isVideoPreviewRequestCurrent("thread-1", "thread-1", 1, 2)).toBe(false);
expect(isVideoPreviewRequestCurrent("thread-1", "thread-1", 2, 2)).toBe(true);
});
});

const environmentId = EnvironmentId.make("environment-local");
const projectId = ProjectId.make("project-1");
const threadId = ThreadId.make("thread-1");
Expand Down
15 changes: 15 additions & 0 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,21 @@ export function revokeBlobPreviewUrl(previewUrl: string | undefined): void {
URL.revokeObjectURL(previewUrl);
}

export async function loadDesktopVideoPreviewUrl(url: string): Promise<string> {
const response = await fetch(url);
if (!response.ok) throw new Error(`Could not load video (${response.status}).`);
return URL.createObjectURL(await response.blob());
}

export function isVideoPreviewRequestCurrent(
requestThreadKey: string,
currentThreadKey: string,
requestId: number,
currentRequestId: number,
): boolean {
return requestThreadKey === currentThreadKey && requestId === currentRequestId;
Comment thread
Bil0000 marked this conversation as resolved.
}

export function revokeUserMessagePreviewUrls(message: ChatMessage): void {
if (message.role !== "user" || !message.attachments) {
return;
Expand Down
80 changes: 72 additions & 8 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ import {
MAX_TERMINALS_PER_GROUP,
type ChatMessage,
isImageAttachment,
isVideoAttachment,
type SessionPhase,
type Thread,
type TurnDiffSummary,
Expand Down Expand Up @@ -360,6 +361,8 @@ import {
cloneComposerImageForRetry,
deriveLockedProvider,
readFileAsDataUrl,
loadDesktopVideoPreviewUrl,
isVideoPreviewRequestCurrent,
reconcileMountedTerminalThreadIds,
resolveBackgroundDraftWorkspaceOptions,
resolveDraftHeroState,
Expand Down Expand Up @@ -1438,8 +1441,17 @@ function ChatViewContent(props: ChatViewProps) {
const localComposerRef = useRef<ChatComposerHandle | null>(null);
const composerRef = useComposerHandleContext() ?? localComposerRef;
const [isWorkspaceFileDragActive, setIsWorkspaceFileDragActive] = useState(false);
const routeThreadKeyRef = useRef(routeThreadKey);
routeThreadKeyRef.current = routeThreadKey;
const videoPreviewRequestIdRef = useRef(0);
const [openingVideoAttachmentId, setOpeningVideoAttachmentId] = useState<string | null>(null);
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
useEffect(() => {
const item = expandedImage?.images[expandedImage.index];
if (item?.type !== "video" || !item.src.startsWith("blob:")) return;
return () => revokeBlobPreviewUrl(item.src);
}, [expandedImage]);
const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);
const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState<
Record<string, ReadonlyArray<CodexFeedbackSubmission>>
Expand Down Expand Up @@ -2504,17 +2516,33 @@ function ChatViewContent(props: ChatViewProps) {
});
}, []);
const serverMessages = activeThread?.messages;
const downloadFileAttachment = useCallback(
const openFileAttachment = useCallback(
async (attachment: ChatFileAttachment) => {
const connection = readPreparedConnection(environmentId);
if (!connection) {
toastManager.add({ type: "error", title: "The environment is not connected." });
return;
}
const isVideo = isVideoAttachment(attachment);
const action = isVideo ? "play" : "download";
const videoPreviewRequestId = isVideo ? ++videoPreviewRequestIdRef.current : 0;
const isCurrentRequest = () =>
!isVideo ||
isVideoPreviewRequestCurrent(
routeThreadKey,
routeThreadKeyRef.current,
videoPreviewRequestId,
videoPreviewRequestIdRef.current,
);
const finishVideoPreviewRequest = () => {
if (videoPreviewRequestIdRef.current === videoPreviewRequestId) {
setOpeningVideoAttachmentId(null);
}
};
if (isVideo) setOpeningVideoAttachmentId(attachment.id);

// fileName and mimeType ride in the signed claims so the download gets
// a real filename and Content-Type even when the anchor's `download`
// attribute is ignored (cross-origin environment servers).
// fileName and mimeType ride in the signed claims so videos render
// inline while other files keep their real download name and type.
const result = await createAttachmentAssetUrl({
environmentId,
input: {
Expand All @@ -2526,27 +2554,56 @@ function ChatViewContent(props: ChatViewProps) {
},
},
});
if (!isCurrentRequest()) {
finishVideoPreviewRequest();
return;
}
if (result._tag === "Failure") {
finishVideoPreviewRequest();
const error = squashAtomCommandFailure(result);
toastManager.add({
type: "error",
title: `Could not download ${attachment.name}`,
title: "Could not " + action + " " + attachment.name,
description: error instanceof Error ? error.message : "The attachment is unavailable.",
});
return;
}

const url = resolveAssetUrl(connection.httpBaseUrl, result.value.relativeUrl);
if (!url) {
toastManager.add({ type: "error", title: `Could not download ${attachment.name}` });
finishVideoPreviewRequest();
toastManager.add({ type: "error", title: "Could not " + action + " " + attachment.name });
return;
}
if (isVideo) {
try {
const previewUrl = window.desktopBridge ? await loadDesktopVideoPreviewUrl(url) : url;
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
if (!isCurrentRequest()) {
revokeBlobPreviewUrl(previewUrl);
return;
}
setExpandedImage({
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
images: [{ src: previewUrl, name: attachment.name, type: "video" }],
index: 0,
});
} catch (error) {
if (!isCurrentRequest()) return;
toastManager.add({
type: "error",
title: "Could not play " + attachment.name,
description: error instanceof Error ? error.message : "The attachment is unavailable.",
});
} finally {
finishVideoPreviewRequest();
}
return;
}
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = attachment.name;
anchor.click();
},
[createAttachmentAssetUrl, environmentId],
[createAttachmentAssetUrl, environmentId, routeThreadKey],
);
const serverAttachmentIds = useMemo(() => {
const attachmentIds = new Set<string>();
Expand Down Expand Up @@ -4389,6 +4446,8 @@ function ChatViewContent(props: ChatViewProps) {
return [];
});
resetLocalDispatch();
videoPreviewRequestIdRef.current += 1;
setOpeningVideoAttachmentId(null);
setExpandedImage(null);
}, [draftId, resetLocalDispatch, threadId]);

Expand Down Expand Up @@ -6844,6 +6903,8 @@ function ChatViewContent(props: ChatViewProps) {
};

const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => {
videoPreviewRequestIdRef.current += 1;
setOpeningVideoAttachmentId(null);
setExpandedImage(preview);
}, []);
const onOpenTurnDiff = useCallback(
Expand Down Expand Up @@ -7139,7 +7200,8 @@ function ChatViewContent(props: ChatViewProps) {
onRevertUserMessage={onRevertUserMessage}
isRevertingCheckpoint={isRevertingCheckpoint}
onImageExpand={onExpandTimelineImage}
onFileDownload={downloadFileAttachment}
onFileOpen={openFileAttachment}
openingVideoAttachmentId={openingVideoAttachmentId}
markdownCwd={gitCwd ?? undefined}
resolvedTheme={resolvedTheme}
timestampFormat={timestampFormat}
Expand Down Expand Up @@ -7320,6 +7382,8 @@ function ChatViewContent(props: ChatViewProps) {
scheduleComposerFocus={scheduleComposerFocus}
setThreadError={setThreadError}
onExpandImage={onExpandTimelineImage}
onFileOpen={openFileAttachment}
openingVideoAttachmentId={openingVideoAttachmentId}
/>
</div>
</div>
Expand Down
Loading
Loading