Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 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
60 changes: 59 additions & 1 deletion apps/server/src/attachmentStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,13 @@ import * as NodePath from "node:path";
import { describe, expect, it } from "vite-plus/test";

import {
attachmentIdBelongsToThread,
collectTextAttachmentRelativePaths,
createAttachmentId,
parseAttachmentIdFromRootEntry,
parseThreadSegmentFromAttachmentId,
resolveAttachmentPathById,
toOwnedThreadAttachmentSegment,
} from "./attachmentStore.ts";

describe("attachmentStore", () => {
Expand Down Expand Up @@ -41,7 +45,61 @@ describe("attachmentStore", () => {
if (!attachmentId) {
return;
}
expect(parseThreadSegmentFromAttachmentId(attachmentId)).toBe("thread-foo");
expect(parseThreadSegmentFromAttachmentId(attachmentId)).toMatch(/^thread-foo-[0-9a-f]{64}$/);
});

it("uses distinct ownership segments for thread ids with the same safe slug", () => {
const slashSegment = toOwnedThreadAttachmentSegment("thread/a");
const dashSegment = toOwnedThreadAttachmentSegment("thread-a");

expect(slashSegment).toMatch(/^thread-a-[0-9a-f]{64}$/);
expect(dashSegment).toMatch(/^thread-a-[0-9a-f]{64}$/);
expect(slashSegment).not.toBe(dashSegment);
const slashAttachmentId = createAttachmentId("thread/a");
expect(slashAttachmentId).toBeTruthy();
if (slashAttachmentId) {
expect(attachmentIdBelongsToThread(slashAttachmentId, "thread/a")).toBe(true);
expect(attachmentIdBelongsToThread(slashAttachmentId, "thread-a")).toBe(false);
}
});

it("parses both flat image files and text attachment directories", () => {
const attachmentId = "thread-1-00000000-0000-4000-8000-000000000001";

expect(parseAttachmentIdFromRootEntry(`${attachmentId}.png`)).toBe(attachmentId);
expect(parseAttachmentIdFromRootEntry(attachmentId)).toBe(attachmentId);
expect(parseAttachmentIdFromRootEntry("unrelated-directory")).toBeNull();
});

it("collects generated text attachment paths for the matching thread", () => {
const attachmentId = createAttachmentId("thread.1");
const otherAttachmentId = createAttachmentId("thread.10");
expect(attachmentId).toBeTruthy();
expect(otherAttachmentId).toBeTruthy();
if (!attachmentId || !otherAttachmentId) {
return;
}
const text = [
`[My%20Notes.md](/tmp/attachments/${attachmentId}/My%20Notes.md)`,
`[Other.md](/tmp/attachments/${otherAttachmentId}/Other.md)`,
].join(" ");

expect(collectTextAttachmentRelativePaths("thread.1", text)).toEqual([
`${attachmentId}/My Notes.md`,
]);
});

it("collects encoded Windows text attachment paths", () => {
const attachmentId = createAttachmentId("thread.1");
expect(attachmentId).toBeTruthy();
if (!attachmentId) {
return;
}
const text = `[Notes.md](C:%5Ctmp%5Cattachments%5C${attachmentId}%5CNotes.md)`;

expect(collectTextAttachmentRelativePaths("thread.1", text)).toEqual([
`${attachmentId}/Notes.md`,
]);
});

it("resolves attachment path by id using the extension that exists on disk", () => {
Expand Down
73 changes: 72 additions & 1 deletion apps/server/src/attachmentStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { inferImageExtension, SAFE_IMAGE_FILE_EXTENSIONS } from "./imageMime.ts"

const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"];
const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80;
const ATTACHMENT_ID_THREAD_HASH_CHARS = 64;
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}";
const ATTACHMENT_ID_PATTERN = new RegExp(
Expand All @@ -35,13 +36,27 @@ export function toSafeThreadAttachmentSegment(threadId: string): string | null {
}

export function createAttachmentId(threadId: string): string | null {
const threadSegment = toSafeThreadAttachmentSegment(threadId);
const threadSegment = toOwnedThreadAttachmentSegment(threadId);
if (!threadSegment) {
return null;
}
return `${threadSegment}-${NodeCrypto.randomUUID()}`;
}

export function toOwnedThreadAttachmentSegment(threadId: string): string | null {
const safeSegment = toSafeThreadAttachmentSegment(threadId);
if (!safeSegment) {
return null;
}
const hash = NodeCrypto.createHash("sha256")
.update(threadId)
.digest("hex")
.slice(0, ATTACHMENT_ID_THREAD_HASH_CHARS);
const slugMaxChars = ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS - ATTACHMENT_ID_THREAD_HASH_CHARS - 1;
const slug = safeSegment.slice(0, slugMaxChars).replace(/[-_]+$/g, "");
return `${slug}-${hash}`;
}

export function parseThreadSegmentFromAttachmentId(attachmentId: string): string | null {
const normalizedId = normalizeAttachmentRelativePath(attachmentId);
if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) {
Expand All @@ -54,6 +69,14 @@ export function parseThreadSegmentFromAttachmentId(attachmentId: string): string
return match[1]?.toLowerCase() ?? null;
}

export function attachmentIdBelongsToThread(attachmentId: string, threadId: string): boolean {
const attachmentThreadSegment = parseThreadSegmentFromAttachmentId(attachmentId);
if (!attachmentThreadSegment) {
return false;
}
return attachmentThreadSegment === toOwnedThreadAttachmentSegment(threadId);
}

export function attachmentRelativePath(attachment: ChatAttachment): string {
switch (attachment.type) {
case "image": {
Expand Down Expand Up @@ -108,3 +131,51 @@ export function parseAttachmentIdFromRelativePath(relativePath: string): string
const id = normalized.slice(0, extensionIndex);
return id.length > 0 && !id.includes(".") ? id : null;
}

export function parseAttachmentIdFromRootEntry(entry: string): string | null {
const normalized = normalizeAttachmentRelativePath(entry);
if (!normalized || normalized.includes("/")) {
return null;
}
const fileAttachmentId = parseAttachmentIdFromRelativePath(normalized);
if (fileAttachmentId) {
return fileAttachmentId;
}
return parseThreadSegmentFromAttachmentId(normalized) ? normalized : null;
}

const TEXT_ATTACHMENT_PATH_PATTERN = new RegExp(
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
`(?:^|/|%5c)(${ATTACHMENT_ID_THREAD_SEGMENT_PATTERN}-${ATTACHMENT_ID_UUID_PATTERN})(?:/|%5c)([^\\s)]+)`,
"gi",
);

export function collectTextAttachmentRelativePaths(
threadId: string,
text: string,
): ReadonlyArray<string> {
const threadSegment = toSafeThreadAttachmentSegment(threadId);
if (!threadSegment) {
return [];
}

const relativePaths = new Set<string>();
for (const match of text.matchAll(TEXT_ATTACHMENT_PATH_PATTERN)) {
const encodedRelativePath = match[0];
let decodedRelativePath = encodedRelativePath;
try {
decodedRelativePath = decodeURIComponent(encodedRelativePath);
} catch {
continue;
}
const normalized = normalizeAttachmentRelativePath(decodedRelativePath);
if (!normalized || normalized.split("/").length !== 2) {
continue;
}
const attachmentId = normalized.slice(0, normalized.indexOf("/"));
if (!attachmentIdBelongsToThread(attachmentId, threadId)) {
continue;
}
relativePaths.add(normalized);
}
Comment thread
cursor[bot] marked this conversation as resolved.
return [...relativePaths];
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
}
1 change: 1 addition & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope,
[WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope,
[WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope,
[WS_METHODS.projectsWriteTextAttachment]: AuthOrchestrationOperateScope,
[WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope,
[WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope,
[WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope,
Expand Down
77 changes: 67 additions & 10 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import * as ThreadPlanProgress from "../ThreadPlanProgress.ts";
import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts";
import { ServerConfig } from "../../config.ts";
import { createAttachmentId } from "../../attachmentStore.ts";

const makeProjectionPipelinePrefixedTestLayer = (prefix: string) =>
OrchestrationProjectionPipelineLive.pipe(
Expand Down Expand Up @@ -797,10 +798,32 @@ it.layer(
const { attachmentsDir } = yield* ServerConfig;
const now = "2026-01-01T00:00:00.000Z";
const threadId = ThreadId.make("Thread Revert.Files");
const keepAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000001";
const removeAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000002";
const otherThreadAttachmentId =
"thread-revert-files-extra-00000000-0000-4000-8000-000000000003";
const keepAttachmentId = createAttachmentId(threadId);
const removeAttachmentId = createAttachmentId(threadId);
const keepTextAttachmentId = createAttachmentId(threadId);
const removeTextAttachmentId = createAttachmentId(threadId);
assert.isNotNull(keepAttachmentId);
assert.isNotNull(removeAttachmentId);
assert.isNotNull(keepTextAttachmentId);
assert.isNotNull(removeTextAttachmentId);
if (
!keepAttachmentId ||
!removeAttachmentId ||
!keepTextAttachmentId ||
!removeTextAttachmentId
) {
return;
}
const keepTextPath = path.join(attachmentsDir, keepTextAttachmentId, "keep.md");
const removeTextPath = path.join(attachmentsDir, removeTextAttachmentId, "remove.md");
const otherThreadAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000003";
const orphanAttachmentId = createAttachmentId(threadId);
const collidingThreadAttachmentId = createAttachmentId("Thread Revert/Files");
assert.isNotNull(orphanAttachmentId);
assert.isNotNull(collidingThreadAttachmentId);
if (!orphanAttachmentId || !collidingThreadAttachmentId) {
return;
}

const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) =>
eventStore
Expand Down Expand Up @@ -890,7 +913,7 @@ it.layer(
threadId,
messageId: MessageId.make("message-keep"),
role: "assistant",
text: "Keep",
text: `Keep [keep.md](${keepTextPath})`,
attachments: [
{
type: "image",
Expand Down Expand Up @@ -943,7 +966,7 @@ it.layer(
threadId,
messageId: MessageId.make("message-remove"),
role: "assistant",
text: "Remove",
text: `Remove [remove.md](${removeTextPath})`,
attachments: [
{
type: "image",
Expand All @@ -963,13 +986,25 @@ it.layer(
const keepPath = path.join(attachmentsDir, `${keepAttachmentId}.png`);
const removePath = path.join(attachmentsDir, `${removeAttachmentId}.png`);
yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true });
yield* fileSystem.makeDirectory(path.dirname(keepTextPath), { recursive: true });
yield* fileSystem.makeDirectory(path.dirname(removeTextPath), { recursive: true });
yield* fileSystem.writeFileString(keepPath, "keep");
yield* fileSystem.writeFileString(removePath, "remove");
yield* fileSystem.writeFileString(keepTextPath, "keep text");
yield* fileSystem.writeFileString(removeTextPath, "remove text");
const otherThreadPath = path.join(attachmentsDir, `${otherThreadAttachmentId}.png`);
yield* fileSystem.writeFileString(otherThreadPath, "other");
const orphanPath = path.join(attachmentsDir, `${orphanAttachmentId}.png`);
const collidingThreadPath = path.join(attachmentsDir, `${collidingThreadAttachmentId}.png`);
yield* fileSystem.writeFileString(orphanPath, "orphan");
yield* fileSystem.writeFileString(collidingThreadPath, "colliding-thread");
assert.isTrue(yield* exists(keepPath));
assert.isTrue(yield* exists(removePath));
assert.isTrue(yield* exists(keepTextPath));
assert.isTrue(yield* exists(removeTextPath));
assert.isTrue(yield* exists(otherThreadPath));
assert.isTrue(yield* exists(orphanPath));
assert.isTrue(yield* exists(collidingThreadPath));

yield* appendAndProject({
type: "thread.reverted",
Expand All @@ -989,15 +1024,19 @@ it.layer(

assert.isTrue(yield* exists(keepPath));
assert.isFalse(yield* exists(removePath));
assert.isTrue(yield* exists(keepTextPath));
assert.isFalse(yield* exists(removeTextPath));
assert.isTrue(yield* exists(otherThreadPath));
assert.isFalse(yield* exists(orphanPath));
assert.isTrue(yield* exists(collidingThreadPath));
}),
);
});

it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-revert-")))(
"OrchestrationProjectionPipeline",
(it) => {
it.effect("removes thread attachment directory when thread is deleted", () =>
it.effect("removes only the deleted thread's referenced attachment entries", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
Expand All @@ -1006,9 +1045,16 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta
const { attachmentsDir } = yield* ServerConfig;
const now = "2026-01-01T00:00:00.000Z";
const threadId = ThreadId.make("Thread Delete.Files");
const attachmentId = "thread-delete-files-00000000-0000-4000-8000-000000000001";
const otherThreadAttachmentId =
"thread-delete-files-extra-00000000-0000-4000-8000-000000000002";
const attachmentId = createAttachmentId(threadId);
const otherThreadAttachmentId = "thread-delete-files-00000000-0000-4000-8000-000000000002";
const orphanAttachmentId = createAttachmentId(threadId);
const collidingThreadAttachmentId = createAttachmentId("Thread Delete/Files");
assert.isNotNull(attachmentId);
assert.isNotNull(orphanAttachmentId);
assert.isNotNull(collidingThreadAttachmentId);
if (!attachmentId || !orphanAttachmentId || !collidingThreadAttachmentId) {
return;
}

const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) =>
eventStore
Expand Down Expand Up @@ -1098,11 +1144,20 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta
attachmentsDir,
`${otherThreadAttachmentId}.png`,
);
const orphanAttachmentPath = path.join(attachmentsDir, `${orphanAttachmentId}.png`);
const collidingThreadAttachmentPath = path.join(
attachmentsDir,
`${collidingThreadAttachmentId}.png`,
);
yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true });
yield* fileSystem.writeFileString(threadAttachmentPath, "delete");
yield* fileSystem.writeFileString(otherThreadAttachmentPath, "other-thread");
yield* fileSystem.writeFileString(orphanAttachmentPath, "orphan");
yield* fileSystem.writeFileString(collidingThreadAttachmentPath, "colliding-thread");
assert.isTrue(yield* exists(threadAttachmentPath));
assert.isTrue(yield* exists(otherThreadAttachmentPath));
assert.isTrue(yield* exists(orphanAttachmentPath));
assert.isTrue(yield* exists(collidingThreadAttachmentPath));

yield* appendAndProject({
type: "thread.deleted",
Expand All @@ -1122,6 +1177,8 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta

assert.isFalse(yield* exists(threadAttachmentPath));
assert.isTrue(yield* exists(otherThreadAttachmentPath));
assert.isFalse(yield* exists(orphanAttachmentPath));
assert.isTrue(yield* exists(collidingThreadAttachmentPath));
}),
);
},
Expand Down
Loading
Loading