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
3 changes: 3 additions & 0 deletions apps/desktop/src/electron/ElectronProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrat
`script-src ${scriptSources.join(" ")}`,
`connect-src ${connectSources.join(" ")}`,
`img-src 'self' ${input.scheme}: blob: data: http: https:`,
// Chat video attachments and Files-panel media previews play from the
// environment's signed asset URLs (http/https) and composer blob URLs.
`media-src 'self' ${input.scheme}: blob: http: https:`,
"style-src 'self' 'unsafe-inline'",
`font-src 'self' ${input.scheme}: data:`,
"worker-src 'self' blob:",
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const clientSettings: ClientSettings = {
planModeEnabled: false,
showSkillsInSlashMenu: false,
providerModelPreferences: {},
rightPanelSide: "right",
sidebarAutoSettleAfterDays: 3,
sidebarAutoSettleOnMerge: true,
sidebarProjectGroupingMode: "repository_path",
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/assets/AssetAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from "@t3tools/contracts";
import {
isWorkspaceImagePreviewPath,
isWorkspaceMediaPreviewPath,
isWorkspacePreviewEntryPath,
WORKSPACE_BROWSER_PREVIEW_EXTENSIONS,
WORKSPACE_IMAGE_PREVIEW_EXTENSIONS,
Expand Down Expand Up @@ -257,7 +258,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
}),
),
);
claims = isWorkspaceImagePreviewPath(resolved.relativePath)
claims = isWorkspaceMediaPreviewPath(resolved.relativePath)
? {
version: 1,
kind: "workspace-file-exact",
Expand Down
31 changes: 29 additions & 2 deletions apps/server/src/attachmentStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,18 @@ import {
normalizeAttachmentRelativePath,
resolveAttachmentRelativePath,
} from "./attachmentPaths.ts";
import { inferImageExtension, SAFE_IMAGE_FILE_EXTENSIONS } from "./imageMime.ts";
import {
inferImageExtension,
inferVideoExtension,
SAFE_IMAGE_FILE_EXTENSIONS,
SAFE_VIDEO_FILE_EXTENSIONS,
} from "./imageMime.ts";

const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"];
const ATTACHMENT_FILENAME_EXTENSIONS = [
...SAFE_IMAGE_FILE_EXTENSIONS,
...SAFE_VIDEO_FILE_EXTENSIONS,
".bin",
];
const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80;
const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*";
const ATTACHMENT_ID_UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
Expand Down Expand Up @@ -63,9 +72,27 @@ export function attachmentRelativePath(attachment: ChatAttachment): string {
});
return `${attachment.id}${extension}`;
}
case "video": {
const extension = inferVideoExtension({
mimeType: attachment.mimeType,
fileName: attachment.name,
});
return `${attachment.id}${extension}`;
}
}
}

/**
* No provider ingests raw video, so adapters forward a video attachment as
* this prompt line; the agent can then read or process the file with tools.
*/
export function videoAttachmentPromptText(input: {
readonly name: string;
readonly path: string;
}): string {
return `[The user attached the video file '${input.name}'. It is saved at: ${input.path}]`;
}

export function resolveAttachmentPath(input: {
readonly attachmentsDir: string;
readonly attachment: ChatAttachment;
Expand Down
31 changes: 25 additions & 6 deletions apps/server/src/imageMime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@ export const SAFE_IMAGE_FILE_EXTENSIONS = new Set([
".webp",
]);

export const VIDEO_EXTENSION_BY_MIME_TYPE: Record<string, string> = {
"video/mp4": ".mp4",
"video/quicktime": ".mov",
"video/webm": ".webm",
"video/x-m4v": ".m4v",
};

export const SAFE_VIDEO_FILE_EXTENSIONS = new Set([".m4v", ".mov", ".mp4", ".webm"]);

// Whether `code` is a character the base64 payload may contain, aside from
// the whitespace handled separately below.
function isBase64Char(code: number): boolean {
Expand Down Expand Up @@ -112,26 +121,36 @@ export function parseBase64DataUrl(
return { mimeType, base64 };
}

export function inferImageExtension(input: { mimeType: string; fileName?: string }): string {
function inferAttachmentExtension(
input: { mimeType: string; fileName?: string },
extensionByMimeType: Record<string, string>,
safeExtensions: ReadonlySet<string>,
): string {
const key = input.mimeType.toLowerCase();
const fromMime = Object.hasOwn(IMAGE_EXTENSION_BY_MIME_TYPE, key)
? IMAGE_EXTENSION_BY_MIME_TYPE[key]
: undefined;
const fromMime = Object.hasOwn(extensionByMimeType, key) ? extensionByMimeType[key] : undefined;
if (fromMime) {
return fromMime;
}

const fromMimeExtension = Mime.getExtension(input.mimeType);
if (fromMimeExtension && SAFE_IMAGE_FILE_EXTENSIONS.has(fromMimeExtension)) {
if (fromMimeExtension && safeExtensions.has(fromMimeExtension)) {
return fromMimeExtension;
}

const fileName = input.fileName?.trim() ?? "";
const extensionMatch = /\.([a-z0-9]{1,8})$/i.exec(fileName);
const fileNameExtension = extensionMatch ? `.${extensionMatch[1]!.toLowerCase()}` : "";
if (SAFE_IMAGE_FILE_EXTENSIONS.has(fileNameExtension)) {
if (safeExtensions.has(fileNameExtension)) {
return fileNameExtension;
}

return ".bin";
}

export function inferImageExtension(input: { mimeType: string; fileName?: string }): string {
return inferAttachmentExtension(input, IMAGE_EXTENSION_BY_MIME_TYPE, SAFE_IMAGE_FILE_EXTENSIONS);
}

export function inferVideoExtension(input: { mimeType: string; fileName?: string }): string {
return inferAttachmentExtension(input, VIDEO_EXTENSION_BY_MIME_TYPE, SAFE_VIDEO_FILE_EXTENSIONS);
}
3 changes: 0 additions & 3 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,9 +338,6 @@ function collectThreadAttachmentRelativePaths(
const relativePaths = new Set<string>();
for (const message of messages) {
for (const attachment of message.attachments ?? []) {
if (attachment.type !== "image") {
continue;
}
const attachmentThreadSegment = parseThreadSegmentFromAttachmentId(attachment.id);
if (!attachmentThreadSegment || attachmentThreadSegment !== threadSegment) {
continue;
Expand Down
42 changes: 31 additions & 11 deletions apps/server/src/orchestration/Normalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import {
type IsoDateTime,
type OrchestrationCommand,
OrchestrationDispatchCommandError,
isProviderSendTurnSupportedVideoMimeType,
PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
PROVIDER_SEND_TURN_MAX_VIDEO_BYTES,
} from "@t3tools/contracts";

import { createAttachmentId, resolveAttachmentPath } from "../attachmentStore.ts";
Expand Down Expand Up @@ -109,16 +111,25 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>
(attachment) =>
Effect.gen(function* () {
const parsed = parseBase64DataUrl(attachment.dataUrl);
if (!parsed || !parsed.mimeType.startsWith("image/")) {
const parsedMimeAccepted =
parsed !== null &&
(attachment.type === "video"
? isProviderSendTurnSupportedVideoMimeType(parsed.mimeType)
: parsed.mimeType.startsWith("image/"));
if (!parsed || !parsedMimeAccepted) {
return yield* new OrchestrationDispatchCommandError({
message: `Invalid image attachment payload for '${attachment.name}'.`,
message: `Invalid ${attachment.type} attachment payload for '${attachment.name}'.`,
});
}

const maxBytes =
attachment.type === "video"
? PROVIDER_SEND_TURN_MAX_VIDEO_BYTES
: PROVIDER_SEND_TURN_MAX_IMAGE_BYTES;
const bytes = Buffer.from(parsed.base64, "base64");
if (bytes.byteLength === 0 || bytes.byteLength > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) {
if (bytes.byteLength === 0 || bytes.byteLength > maxBytes) {
return yield* new OrchestrationDispatchCommandError({
message: `Image attachment '${attachment.name}' is empty or too large.`,
message: `Attachment '${attachment.name}' is empty or too large.`,
});
}

Expand All @@ -129,13 +140,22 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>
});
}

const persistedAttachment = {
type: "image" as const,
id: attachmentId,
name: attachment.name,
mimeType: parsed.mimeType.toLowerCase(),
sizeBytes: bytes.byteLength,
};
const persistedAttachment =
attachment.type === "video"
? {
type: "video" as const,
id: attachmentId,
name: attachment.name,
mimeType: parsed.mimeType.toLowerCase(),
sizeBytes: bytes.byteLength,
}
: {
type: "image" as const,
id: attachmentId,
name: attachment.name,
mimeType: parsed.mimeType.toLowerCase(),
sizeBytes: bytes.byteLength,
};

const attachmentPath = resolveAttachmentPath({
attachmentsDir: serverConfig.attachmentsDir,
Expand Down
13 changes: 12 additions & 1 deletion apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ import * as Ref from "effect/Ref";
import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";

import { resolveAttachmentPath } from "../../attachmentStore.ts";
import { resolveAttachmentPath, videoAttachmentPromptText } from "../../attachmentStore.ts";
import { ServerConfig } from "../../config.ts";
import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts";
Expand Down Expand Up @@ -1265,6 +1265,17 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* (

for (const attachment of input.attachments ?? []) {
if (attachment.type !== "image") {
// Claude ingests no raw video; hand the agent the stored file's path.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High Layers/ClaudeAdapter.ts:1268

A non-image attachment is silently omitted when resolveAttachmentPath returns null, so sendTurn succeeds without sending the attachment or notifying either the user or agent. Unlike the image branch, this path does not validate or read the file; return ProviderAdapterRequestError for an invalid path and propagate filesystem read failures before adding the prompt.

Also found in 1 other location(s)

apps/server/src/provider/Layers/GrokAdapter.ts:975

The new video branch returns a text prompt immediately without checking that attachmentPath exists or is readable. resolveAttachmentPath only performs path normalization/containment and does not touch the filesystem, whereas the image branch's fileSystem.readFile surfaces missing-file errors. If attachment metadata survives while its stored video is missing or inaccessible, sendTurn succeeds and tells Grok the file is available at a nonexistent path, leaving the agent unable to process the attachment instead of returning a clear request error.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/ClaudeAdapter.ts around line 1268:

A non-image attachment is silently omitted when `resolveAttachmentPath` returns `null`, so `sendTurn` succeeds without sending the attachment or notifying either the user or agent. Unlike the image branch, this path does not validate or read the file; return `ProviderAdapterRequestError` for an invalid path and propagate filesystem read failures before adding the prompt.

Also found in 1 other location(s):
- apps/server/src/provider/Layers/GrokAdapter.ts:975 -- The new video branch returns a text prompt immediately without checking that `attachmentPath` exists or is readable. `resolveAttachmentPath` only performs path normalization/containment and does not touch the filesystem, whereas the image branch's `fileSystem.readFile` surfaces missing-file errors. If attachment metadata survives while its stored video is missing or inaccessible, `sendTurn` succeeds and tells Grok the file is available at a nonexistent path, leaving the agent unable to process the attachment instead of returning a clear request error.

const videoPath = resolveAttachmentPath({
attachmentsDir: dependencies.attachmentsDir,
attachment,
});
if (videoPath) {
sdkContent.push({
type: "text",
text: videoAttachmentPromptText({ name: attachment.name, path: videoPath }),
});
}
continue;
}

Expand Down
9 changes: 8 additions & 1 deletion apps/server/src/provider/Layers/CodexAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ import {
type ProviderAdapterError,
} from "../Errors.ts";
import { type CodexAdapterShape } from "../Services/CodexAdapter.ts";
import { resolveAttachmentPath } from "../../attachmentStore.ts";
import { resolveAttachmentPath, videoAttachmentPromptText } from "../../attachmentStore.ts";
import { ServerConfig } from "../../config.ts";
import {
CodexResumeCursorSchema,
Expand Down Expand Up @@ -1780,6 +1780,13 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
detail: `Invalid attachment id '${attachment.id}'.`,
});
}
if (attachment.type !== "image") {
// No raw-video ingestion; pass the stored file's path.
return {
type: "text" as const,
text: videoAttachmentPromptText({ name: attachment.name, path: attachmentPath }),
};
}
const bytes = yield* fileSystem.readFile(attachmentPath).pipe(
Effect.mapError(
(cause) =>
Expand Down
14 changes: 6 additions & 8 deletions apps/server/src/provider/Layers/CodexSessionRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,10 +111,9 @@ export interface CodexSessionRuntimeOptions {

export interface CodexSessionRuntimeSendTurnInput {
readonly input?: string;
readonly attachments?: ReadonlyArray<{
readonly type: "image";
readonly url: string;
}>;
readonly attachments?: ReadonlyArray<
{ readonly type: "image"; readonly url: string } | { readonly type: "text"; readonly text: string }
>;
readonly model?: string;
readonly serviceTier?: CodexServiceTier | undefined;
readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort | undefined;
Expand Down Expand Up @@ -368,10 +367,9 @@ export function buildTurnStartParams(input: {
readonly threadId: string;
readonly runtimeMode: RuntimeMode;
readonly prompt?: string;
readonly attachments?: ReadonlyArray<{
readonly type: "image";
readonly url: string;
}>;
readonly attachments?: ReadonlyArray<
{ readonly type: "image"; readonly url: string } | { readonly type: "text"; readonly text: string }
>;
readonly model?: string;
readonly serviceTier?: CodexServiceTier;
readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort;
Expand Down
13 changes: 12 additions & 1 deletion apps/server/src/provider/Layers/CursorAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawne
import * as EffectAcpErrors from "effect-acp/errors";
import type * as EffectAcpSchema from "effect-acp/schema";

import { resolveAttachmentPath } from "../../attachmentStore.ts";
import { resolveAttachmentPath, videoAttachmentPromptText } from "../../attachmentStore.ts";
import { ServerConfig } from "../../config.ts";
import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
import {
Expand Down Expand Up @@ -983,6 +983,17 @@ export function makeCursorAdapter(
detail: `Invalid attachment id '${attachment.id}'.`,
});
}
if (attachment.type !== "image") {
// No raw-video ingestion; pass the stored file's path.
promptParts.push({
type: "text",
text: videoAttachmentPromptText({
name: attachment.name,
path: attachmentPath,
}),
});
continue;
}
const bytes = yield* fileSystem.readFile(attachmentPath).pipe(
Effect.mapError(
(cause) =>
Expand Down
12 changes: 11 additions & 1 deletion apps/server/src/provider/Layers/GrokAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawne
import * as EffectAcpErrors from "effect-acp/errors";
import type * as EffectAcpSchema from "effect-acp/schema";

import { resolveAttachmentPath } from "../../attachmentStore.ts";
import { resolveAttachmentPath, videoAttachmentPromptText } from "../../attachmentStore.ts";
import { ServerConfig } from "../../config.ts";
import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
import {
Expand Down Expand Up @@ -972,6 +972,16 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
detail: `Invalid attachment id '${attachment.id}'.`,
});
}
if (attachment.type !== "image") {
// No raw-video ingestion; pass the stored file's path.
return {
type: "text",
text: videoAttachmentPromptText({
name: attachment.name,
path: attachmentPath,
}),
} satisfies EffectAcpSchema.ContentBlock;
}
const bytes = yield* fileSystem.readFile(attachmentPath).pipe(
Effect.mapError(
(cause) =>
Expand Down
24 changes: 24 additions & 0 deletions apps/server/src/workspace/WorkspaceEntries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,30 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => {
expect(result.truncated).toBe(false);
}),
);

it.effect("includeHidden lists dotfiles and gitignored files, still skipping .git and node_modules", () =>
Effect.gen(function* () {
const cwd = yield* makeTempDir();
yield* writeTextFile(cwd, "src/index.ts");
yield* writeTextFile(cwd, ".env");
yield* writeTextFile(cwd, ".gitignore", "media/\n");
yield* writeTextFile(cwd, "media/clip.mp4");
yield* writeTextFile(cwd, ".git/HEAD");
yield* writeTextFile(cwd, "node_modules/pkg/index.js");

const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
const result = yield* workspaceEntries.list({ cwd, includeHidden: true });
const paths = result.entries.map((entry) => entry.path);

expect(paths).toContain("src/index.ts");
expect(paths).toContain(".env");
expect(paths).toContain("media");
expect(paths).toContain("media/clip.mp4");
expect(paths.some((entryPath) => entryPath.startsWith(".git/") || entryPath === ".git")).toBe(false);
expect(paths.some((entryPath) => entryPath.startsWith("node_modules"))).toBe(false);
expect(result.truncated).toBe(false);
}),
);
});

describe("search", () => {
Expand Down
Loading
Loading