Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
4 changes: 3 additions & 1 deletion apps/mobile/src/features/usage/usageProviders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe
* Series and table order. The chart stacks providers from the bottom in this
* order, so it also fixes which band sits on top of the bars.
*/
export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude", "grok"];
export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude", "grok", "opencode"];

export const PROVIDER_LABEL: Record<UsageProviderKind, string> = {
claude: "Claude Code",
codex: "Codex",
grok: "Grok Build",
opencode: "OpenCode",
};

/**
Expand All @@ -23,5 +24,6 @@ export function useProviderColors(): Record<UsageProviderKind, string> {
claude: "#d97757",
codex: scheme === "dark" ? "#e6e6e6" : "#3c3c43",
grok: scheme === "dark" ? "#a1a1aa" : "#52525b",
opencode: scheme === "dark" ? "#8a8a9a" : "#5a5a6a",
};
}
61 changes: 61 additions & 0 deletions apps/server/src/assets/AssetAccess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,67 @@ describe("AssetAccess", () => {
});
}).pipe(Effect.provide(testLayer)),
);

it.effect("serves document attachments inline when a viewer requests it", () =>
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-pdf";
const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.pdf`);
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: "report.pdf",
mimeType: "application/pdf",
disposition: "inline",
},
});
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: "report.pdf",
mimeType: "application/pdf",
});
}).pipe(Effect.provide(testLayer)),
);

it.effect("keeps inline requests for other attachment types as downloads", () =>
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-000000000002-zip";
const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.zip`);
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: "archive.zip",
mimeType: "text/html",
disposition: "inline",
},
});
const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
const separatorIndex = suffix.indexOf("/");

expect(
yield* resolveAsset(suffix.slice(0, separatorIndex), suffix.slice(separatorIndex + 1)),
).toMatchObject({ kind: "file", path: attachmentPath, download: true });
}).pipe(Effect.provide(testLayer)),
);
it.effect("issues project favicon capabilities with a signed fallback", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand Down
32 changes: 26 additions & 6 deletions apps/server/src/assets/AssetAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,14 @@ 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;
// Extensions a document viewer may request inline. The extension comes from
// the attachment id the server assigned, never from the client's mime type.
const INLINE_DOCUMENT_EXTENSIONS = new Set(["pdf", "html", "htm"]);
const INLINE_DOCUMENT_MIME_TYPES: Record<string, string> = {
pdf: "application/pdf",
html: "text/html",
htm: "text/html",
};
const PREVIEW_ASSET_EXTENSIONS = new Set([
...WORKSPACE_BROWSER_PREVIEW_EXTENSIONS,
...WORKSPACE_IMAGE_PREVIEW_EXTENSIONS,
Expand Down Expand Up @@ -362,19 +370,31 @@ 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. Videos and images
// render inline; other generic files download.
const isGenericFile = parseAttachmentFileExtension(input.resource.attachmentId) !== null;
// render inline. Other generic files download, unless a document viewer
// asked for inline and the stored extension is one a browser can show.
const extension = parseAttachmentFileExtension(input.resource.attachmentId);
const isGenericFile = extension !== null;
const videoMimeType = input.resource.mimeType?.split(";", 1)[0]?.trim() ?? "";
const isVideo = INLINE_VIDEO_MIME_TYPE_PATTERN.test(videoMimeType);
const inlineDocumentMimeType =
input.resource.disposition === "inline" &&
extension !== null &&
INLINE_DOCUMENT_EXTENSIONS.has(extension)
? INLINE_DOCUMENT_MIME_TYPES[extension]
: undefined;
claims = {
version: 1,
kind: "attachment",
attachmentId: input.resource.attachmentId,
...(isGenericFile && !isVideo ? { download: true } : {}),
...(input.resource.fileName !== undefined ? { fileName: input.resource.fileName } : {}),
...(input.resource.mimeType !== undefined
? { mimeType: isVideo ? videoMimeType : input.resource.mimeType }
...(isGenericFile && !isVideo && inlineDocumentMimeType === undefined
? { download: true }
: {}),
...(input.resource.fileName !== undefined ? { fileName: input.resource.fileName } : {}),
...(inlineDocumentMimeType !== undefined
? { mimeType: inlineDocumentMimeType }
: input.resource.mimeType !== undefined
? { mimeType: isVideo ? videoMimeType : input.resource.mimeType }
: {}),
expiresAt,
};
fileName = input.resource.fileName ?? path.basename(attachmentPath);
Expand Down
13 changes: 13 additions & 0 deletions apps/server/src/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,19 @@ describe("assetResponseHeaders", () => {
"X-Content-Type-Options": "nosniff",
});
});
it("serves inline attachment documents with their declared mime type", () => {
expect(
assetResponseHeaders("/attachments/upload.bin", { mimeType: "application/pdf" }),
).toMatchObject({
"Content-Type": "application/pdf",
});
expect(
assetResponseHeaders("/attachments/upload.bin", { mimeType: "text/html" }),
).toMatchObject({
"Content-Type": "text/html; charset=utf-8",
"Content-Security-Policy": "sandbox allow-scripts allow-forms allow-popups allow-modals",
});
});
it("serves HTML assets as utf-8 inside a sandboxed origin", () => {
for (const path of ["/workspace/page.html", "/workspace/PAGE.HTM", "/tmp/report.html"]) {
expect(assetResponseHeaders(path)).toMatchObject({
Expand Down
26 changes: 19 additions & 7 deletions apps/server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ const isSafeDownloadMimeType = (mimeType: string): boolean =>
!/(?:^text\/html$|\/xml(?:$|-)|\+xml$)/i.test(mimeType.trim().toLowerCase());
const isSafeInlineVideoMimeType = (mimeType: string): boolean =>
DOWNLOAD_MIME_TYPE_PATTERN.test(mimeType) && mimeType.toLowerCase().startsWith("video/");
const isSafeInlineDocumentMimeType = (mimeType: string): boolean =>
mimeType.toLowerCase() === "application/pdf" || mimeType.toLowerCase() === "text/html";

/** RFC 6266 disposition with an ASCII fallback name plus a UTF-8 `filename*`. */
export function downloadContentDisposition(fileName?: string): string {
Expand Down Expand Up @@ -93,7 +95,7 @@ export function assetResponseHeaders(
},
): Record<string, string> {
const lowerPath = filePath.toLowerCase();
const inlineVideoMimeType = options?.mimeType?.split(";", 1)[0]?.trim();
const inlineMimeType = options?.mimeType?.split(";", 1)[0]?.trim();
return {
"Cache-Control": "private, max-age=3600",
"X-Content-Type-Options": "nosniff",
Expand All @@ -106,14 +108,24 @@ export function assetResponseHeaders(
? options.mimeType
: "application/octet-stream",
}
: inlineVideoMimeType !== undefined && isSafeInlineVideoMimeType(inlineVideoMimeType)
? { "Content-Type": inlineVideoMimeType }
: lowerPath.endsWith(".html") || lowerPath.endsWith(".htm")
: inlineMimeType !== undefined && isSafeInlineVideoMimeType(inlineMimeType)
? { "Content-Type": inlineMimeType }
: inlineMimeType !== undefined && isSafeInlineDocumentMimeType(inlineMimeType)
? {
"Content-Type": "text/html; charset=utf-8",
"Content-Security-Policy": HTML_CONTENT_SECURITY_POLICY,
"Content-Type":
inlineMimeType.toLowerCase() === "text/html"
? "text/html; charset=utf-8"
: "application/pdf",
...(inlineMimeType.toLowerCase() === "text/html"
? { "Content-Security-Policy": HTML_CONTENT_SECURITY_POLICY }
: {}),
}
: {}),
: lowerPath.endsWith(".html") || lowerPath.endsWith(".htm")
? {
"Content-Type": "text/html; charset=utf-8",
"Content-Security-Policy": HTML_CONTENT_SECURITY_POLICY,
}
: {}),
...(!options?.download && lowerPath.endsWith(".svg")
? { "Content-Security-Policy": SVG_CONTENT_SECURITY_POLICY }
: {}),
Expand Down
Loading