authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-01 18:49:25-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-01 20:06:21-08:00
loga56418f8bc9866ab114c85b4e73a7f95987c3469
tree0d617efbcc51530c20cd1e35e8052bae174f486a
parent596e1590dfd602ffcf1efd77b5d9a0eac465f374

Reuse attachment thread segment sanitizer in projection pipeline

- export and reuse `toSafeThreadAttachmentSegment` from `attachmentStore` - align projection attachment path logic with store-safe thread IDs - update projection pipeline tests to cover thread IDs with spaces

3 files changed, 36 insertions(+), 47 deletions(-)

apps/server/src/attachmentStore.ts+1-1
...@@ -13,7 +13,7 @@ import { inferImageExtension, SAFE_IMAGE_FILE_EXTENSIONS } from "./imageMime.ts"...@@ -13,7 +13,7 @@ import { inferImageExtension, SAFE_IMAGE_FILE_EXTENSIONS } from "./imageMime.ts"
13const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"];13const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"];
14const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80;14const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80;
1515
16function toSafeThreadAttachmentSegment(threadId: string): string | null {16export function toSafeThreadAttachmentSegment(threadId: string): string | null {
17 const segment = threadId17 const segment = threadId
18 .trim()18 .trim()
19 .replace(/[^a-z0-9_-]+/gi, "-")19 .replace(/[^a-z0-9_-]+/gi, "-")
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+29-24
...@@ -733,6 +733,9 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {...@@ -733,6 +733,9 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
733 const projectionPipeline = yield* OrchestrationProjectionPipeline;733 const projectionPipeline = yield* OrchestrationProjectionPipeline;
734 const eventStore = yield* OrchestrationEventStore;734 const eventStore = yield* OrchestrationEventStore;
735 const now = new Date().toISOString();735 const now = new Date().toISOString();
736 const threadId = ThreadId.makeUnsafe("thread revert.files");
737 const keepAttachmentId = "thread-revert-files-keep";
738 const removeAttachmentId = "thread-revert-files-remove";
736739
737 const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) =>740 const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) =>
738 eventStore741 eventStore
...@@ -764,14 +767,14 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {...@@ -764,14 +767,14 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
764 type: "thread.created",767 type: "thread.created",
765 eventId: EventId.makeUnsafe("evt-revert-files-2"),768 eventId: EventId.makeUnsafe("evt-revert-files-2"),
766 aggregateKind: "thread",769 aggregateKind: "thread",
767 aggregateId: ThreadId.makeUnsafe("thread-revert-files"),770 aggregateId: threadId,
768 occurredAt: now,771 occurredAt: now,
769 commandId: CommandId.makeUnsafe("cmd-revert-files-2"),772 commandId: CommandId.makeUnsafe("cmd-revert-files-2"),
770 causationEventId: null,773 causationEventId: null,
771 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-2"),774 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-2"),
772 metadata: {},775 metadata: {},
773 payload: {776 payload: {
774 threadId: ThreadId.makeUnsafe("thread-revert-files"),777 threadId,
775 projectId: ProjectId.makeUnsafe("project-revert-files"),778 projectId: ProjectId.makeUnsafe("project-revert-files"),
776 title: "Thread Revert Files",779 title: "Thread Revert Files",
777 model: "gpt-5-codex",780 model: "gpt-5-codex",
...@@ -786,14 +789,14 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {...@@ -786,14 +789,14 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
786 type: "thread.turn-diff-completed",789 type: "thread.turn-diff-completed",
787 eventId: EventId.makeUnsafe("evt-revert-files-3"),790 eventId: EventId.makeUnsafe("evt-revert-files-3"),
788 aggregateKind: "thread",791 aggregateKind: "thread",
789 aggregateId: ThreadId.makeUnsafe("thread-revert-files"),792 aggregateId: threadId,
790 occurredAt: now,793 occurredAt: now,
791 commandId: CommandId.makeUnsafe("cmd-revert-files-3"),794 commandId: CommandId.makeUnsafe("cmd-revert-files-3"),
792 causationEventId: null,795 causationEventId: null,
793 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-3"),796 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-3"),
794 metadata: {},797 metadata: {},
795 payload: {798 payload: {
796 threadId: ThreadId.makeUnsafe("thread-revert-files"),799 threadId,
797 turnId: TurnId.makeUnsafe("turn-keep"),800 turnId: TurnId.makeUnsafe("turn-keep"),
798 checkpointTurnCount: 1,801 checkpointTurnCount: 1,
799 checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert-files/turn/1"),802 checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert-files/turn/1"),
...@@ -808,21 +811,21 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {...@@ -808,21 +811,21 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
808 type: "thread.message-sent",811 type: "thread.message-sent",
809 eventId: EventId.makeUnsafe("evt-revert-files-4"),812 eventId: EventId.makeUnsafe("evt-revert-files-4"),
810 aggregateKind: "thread",813 aggregateKind: "thread",
811 aggregateId: ThreadId.makeUnsafe("thread-revert-files"),814 aggregateId: threadId,
812 occurredAt: now,815 occurredAt: now,
813 commandId: CommandId.makeUnsafe("cmd-revert-files-4"),816 commandId: CommandId.makeUnsafe("cmd-revert-files-4"),
814 causationEventId: null,817 causationEventId: null,
815 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-4"),818 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-4"),
816 metadata: {},819 metadata: {},
817 payload: {820 payload: {
818 threadId: ThreadId.makeUnsafe("thread-revert-files"),821 threadId,
819 messageId: MessageId.makeUnsafe("message-keep"),822 messageId: MessageId.makeUnsafe("message-keep"),
820 role: "assistant",823 role: "assistant",
821 text: "Keep",824 text: "Keep",
822 attachments: [825 attachments: [
823 {826 {
824 type: "image",827 type: "image",
825 id: "thread-revert-files-keep",828 id: keepAttachmentId,
826 name: "keep.png",829 name: "keep.png",
827 mimeType: "image/png",830 mimeType: "image/png",
828 sizeBytes: 5,831 sizeBytes: 5,
...@@ -839,14 +842,14 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {...@@ -839,14 +842,14 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
839 type: "thread.turn-diff-completed",842 type: "thread.turn-diff-completed",
840 eventId: EventId.makeUnsafe("evt-revert-files-5"),843 eventId: EventId.makeUnsafe("evt-revert-files-5"),
841 aggregateKind: "thread",844 aggregateKind: "thread",
842 aggregateId: ThreadId.makeUnsafe("thread-revert-files"),845 aggregateId: threadId,
843 occurredAt: now,846 occurredAt: now,
844 commandId: CommandId.makeUnsafe("cmd-revert-files-5"),847 commandId: CommandId.makeUnsafe("cmd-revert-files-5"),
845 causationEventId: null,848 causationEventId: null,
846 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-5"),849 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-5"),
847 metadata: {},850 metadata: {},
848 payload: {851 payload: {
849 threadId: ThreadId.makeUnsafe("thread-revert-files"),852 threadId,
850 turnId: TurnId.makeUnsafe("turn-remove"),853 turnId: TurnId.makeUnsafe("turn-remove"),
851 checkpointTurnCount: 2,854 checkpointTurnCount: 2,
852 checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert-files/turn/2"),855 checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert-files/turn/2"),
...@@ -861,21 +864,21 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {...@@ -861,21 +864,21 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
861 type: "thread.message-sent",864 type: "thread.message-sent",
862 eventId: EventId.makeUnsafe("evt-revert-files-6"),865 eventId: EventId.makeUnsafe("evt-revert-files-6"),
863 aggregateKind: "thread",866 aggregateKind: "thread",
864 aggregateId: ThreadId.makeUnsafe("thread-revert-files"),867 aggregateId: threadId,
865 occurredAt: now,868 occurredAt: now,
866 commandId: CommandId.makeUnsafe("cmd-revert-files-6"),869 commandId: CommandId.makeUnsafe("cmd-revert-files-6"),
867 causationEventId: null,870 causationEventId: null,
868 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-6"),871 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-6"),
869 metadata: {},872 metadata: {},
870 payload: {873 payload: {
871 threadId: ThreadId.makeUnsafe("thread-revert-files"),874 threadId,
872 messageId: MessageId.makeUnsafe("message-remove"),875 messageId: MessageId.makeUnsafe("message-remove"),
873 role: "assistant",876 role: "assistant",
874 text: "Remove",877 text: "Remove",
875 attachments: [878 attachments: [
876 {879 {
877 type: "image",880 type: "image",
878 id: "thread-revert-files-remove",881 id: removeAttachmentId,
879 name: "remove.png",882 name: "remove.png",
880 mimeType: "image/png",883 mimeType: "image/png",
881 sizeBytes: 5,884 sizeBytes: 5,
...@@ -891,12 +894,12 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {...@@ -891,12 +894,12 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
891 const keepPath = path.join(894 const keepPath = path.join(
892 stateDir,895 stateDir,
893 "attachments",896 "attachments",
894 "thread-revert-files-keep.png",897 `${keepAttachmentId}.png`,
895 );898 );
896 const removePath = path.join(899 const removePath = path.join(
897 stateDir,900 stateDir,
898 "attachments",901 "attachments",
899 "thread-revert-files-remove.png",902 `${removeAttachmentId}.png`,
900 );903 );
901 fs.mkdirSync(path.join(stateDir, "attachments"), { recursive: true });904 fs.mkdirSync(path.join(stateDir, "attachments"), { recursive: true });
902 fs.writeFileSync(keepPath, Buffer.from("keep"));905 fs.writeFileSync(keepPath, Buffer.from("keep"));
...@@ -908,14 +911,14 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {...@@ -908,14 +911,14 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
908 type: "thread.reverted",911 type: "thread.reverted",
909 eventId: EventId.makeUnsafe("evt-revert-files-7"),912 eventId: EventId.makeUnsafe("evt-revert-files-7"),
910 aggregateKind: "thread",913 aggregateKind: "thread",
911 aggregateId: ThreadId.makeUnsafe("thread-revert-files"),914 aggregateId: threadId,
912 occurredAt: now,915 occurredAt: now,
913 commandId: CommandId.makeUnsafe("cmd-revert-files-7"),916 commandId: CommandId.makeUnsafe("cmd-revert-files-7"),
914 causationEventId: null,917 causationEventId: null,
915 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-7"),918 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-7"),
916 metadata: {},919 metadata: {},
917 payload: {920 payload: {
918 threadId: ThreadId.makeUnsafe("thread-revert-files"),921 threadId,
919 turnCount: 1,922 turnCount: 1,
920 },923 },
921 });924 });
...@@ -939,6 +942,8 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {...@@ -939,6 +942,8 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
939 const projectionPipeline = yield* OrchestrationProjectionPipeline;942 const projectionPipeline = yield* OrchestrationProjectionPipeline;
940 const eventStore = yield* OrchestrationEventStore;943 const eventStore = yield* OrchestrationEventStore;
941 const now = new Date().toISOString();944 const now = new Date().toISOString();
945 const threadId = ThreadId.makeUnsafe("thread delete.files");
946 const attachmentId = "thread-delete-files-att-1";
942947
943 const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) =>948 const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) =>
944 eventStore949 eventStore
...@@ -970,14 +975,14 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {...@@ -970,14 +975,14 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
970 type: "thread.created",975 type: "thread.created",
971 eventId: EventId.makeUnsafe("evt-delete-files-2"),976 eventId: EventId.makeUnsafe("evt-delete-files-2"),
972 aggregateKind: "thread",977 aggregateKind: "thread",
973 aggregateId: ThreadId.makeUnsafe("thread-delete-files"),978 aggregateId: threadId,
974 occurredAt: now,979 occurredAt: now,
975 commandId: CommandId.makeUnsafe("cmd-delete-files-2"),980 commandId: CommandId.makeUnsafe("cmd-delete-files-2"),
976 causationEventId: null,981 causationEventId: null,
977 correlationId: CorrelationId.makeUnsafe("cmd-delete-files-2"),982 correlationId: CorrelationId.makeUnsafe("cmd-delete-files-2"),
978 metadata: {},983 metadata: {},
979 payload: {984 payload: {
980 threadId: ThreadId.makeUnsafe("thread-delete-files"),985 threadId,
981 projectId: ProjectId.makeUnsafe("project-delete-files"),986 projectId: ProjectId.makeUnsafe("project-delete-files"),
982 title: "Thread Delete Files",987 title: "Thread Delete Files",
983 model: "gpt-5-codex",988 model: "gpt-5-codex",
...@@ -992,21 +997,21 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {...@@ -992,21 +997,21 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
992 type: "thread.message-sent",997 type: "thread.message-sent",
993 eventId: EventId.makeUnsafe("evt-delete-files-3"),998 eventId: EventId.makeUnsafe("evt-delete-files-3"),
994 aggregateKind: "thread",999 aggregateKind: "thread",
995 aggregateId: ThreadId.makeUnsafe("thread-delete-files"),1000 aggregateId: threadId,
996 occurredAt: now,1001 occurredAt: now,
997 commandId: CommandId.makeUnsafe("cmd-delete-files-3"),1002 commandId: CommandId.makeUnsafe("cmd-delete-files-3"),
998 causationEventId: null,1003 causationEventId: null,
999 correlationId: CorrelationId.makeUnsafe("cmd-delete-files-3"),1004 correlationId: CorrelationId.makeUnsafe("cmd-delete-files-3"),
1000 metadata: {},1005 metadata: {},
1001 payload: {1006 payload: {
1002 threadId: ThreadId.makeUnsafe("thread-delete-files"),1007 threadId,
1003 messageId: MessageId.makeUnsafe("message-delete-files"),1008 messageId: MessageId.makeUnsafe("message-delete-files"),
1004 role: "user",1009 role: "user",
1005 text: "Delete",1010 text: "Delete",
1006 attachments: [1011 attachments: [
1007 {1012 {
1008 type: "image",1013 type: "image",
1009 id: "thread-delete-files-att-1",1014 id: attachmentId,
1010 name: "delete.png",1015 name: "delete.png",
1011 mimeType: "image/png",1016 mimeType: "image/png",
1012 sizeBytes: 5,1017 sizeBytes: 5,
...@@ -1019,7 +1024,7 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {...@@ -1019,7 +1024,7 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
1019 },1024 },
1020 });1025 });
10211026
1022 const threadAttachmentPath = path.join(stateDir, "attachments", "thread-delete-files-att-1.png");1027 const threadAttachmentPath = path.join(stateDir, "attachments", `${attachmentId}.png`);
1023 fs.mkdirSync(path.join(stateDir, "attachments"), { recursive: true });1028 fs.mkdirSync(path.join(stateDir, "attachments"), { recursive: true });
1024 fs.writeFileSync(threadAttachmentPath, Buffer.from("delete"));1029 fs.writeFileSync(threadAttachmentPath, Buffer.from("delete"));
1025 assert.equal(fs.existsSync(threadAttachmentPath), true);1030 assert.equal(fs.existsSync(threadAttachmentPath), true);
...@@ -1028,14 +1033,14 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {...@@ -1028,14 +1033,14 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
1028 type: "thread.deleted",1033 type: "thread.deleted",
1029 eventId: EventId.makeUnsafe("evt-delete-files-4"),1034 eventId: EventId.makeUnsafe("evt-delete-files-4"),
1030 aggregateKind: "thread",1035 aggregateKind: "thread",
1031 aggregateId: ThreadId.makeUnsafe("thread-delete-files"),1036 aggregateId: threadId,
1032 occurredAt: now,1037 occurredAt: now,
1033 commandId: CommandId.makeUnsafe("cmd-delete-files-4"),1038 commandId: CommandId.makeUnsafe("cmd-delete-files-4"),
1034 causationEventId: null,1039 causationEventId: null,
1035 correlationId: CorrelationId.makeUnsafe("cmd-delete-files-4"),1040 correlationId: CorrelationId.makeUnsafe("cmd-delete-files-4"),
1036 metadata: {},1041 metadata: {},
1037 payload: {1042 payload: {
1038 threadId: ThreadId.makeUnsafe("thread-delete-files"),1043 threadId,
1039 deletedAt: now,1044 deletedAt: now,
1040 },1045 },
1041 });1046 });
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+6-22
...@@ -37,7 +37,7 @@ import {...@@ -37,7 +37,7 @@ import {
37 OrchestrationProjectionPipeline,37 OrchestrationProjectionPipeline,
38 type OrchestrationProjectionPipelineShape,38 type OrchestrationProjectionPipelineShape,
39} from "../Services/ProjectionPipeline.ts";39} from "../Services/ProjectionPipeline.ts";
40import { attachmentRelativePath } from "../../attachmentStore.ts";40import { attachmentRelativePath, toSafeThreadAttachmentSegment } from "../../attachmentStore.ts";
4141
42export const ORCHESTRATION_PROJECTOR_NAMES = {42export const ORCHESTRATION_PROJECTOR_NAMES = {
43 projects: "projection.projects",43 projects: "projection.projects",
...@@ -66,27 +66,11 @@ interface AttachmentSideEffects {...@@ -66,27 +66,11 @@ interface AttachmentSideEffects {
66 readonly prunedThreadRelativePaths: Map<string, Set<string>>;66 readonly prunedThreadRelativePaths: Map<string, Set<string>>;
67}67}
6868
69function toSafeThreadAttachmentSegment(threadId: string): string | null {69const materializeAttachmentsForProjection = Effect.fn(
70 const segment = encodeURIComponent(threadId);70 (input: {
71 if (71 readonly attachments: ReadonlyArray<ChatAttachment>;
72 segment.length === 0 ||72 }) => Effect.succeed(input.attachments.length === 0 ? [] : input.attachments),
73 segment === "." ||73);
74 segment === ".." ||
75 segment.includes("/") ||
76 segment.includes("\\") ||
77 segment.includes("\0")
78 ) {
79 return null;
80 }
81 return segment;
82}
83
84const materializeAttachmentsForProjection = Effect.fn(function* (input: {
85 readonly attachments: ReadonlyArray<ChatAttachment>;
86}) {
87 if (input.attachments.length === 0) return [];
88 return input.attachments;
89});
9074
91function extractActivityRequestId(payload: unknown): ApprovalRequestId | null {75function extractActivityRequestId(payload: unknown): ApprovalRequestId | null {
92 if (typeof payload !== "object" || payload === null) {76 if (typeof payload !== "object" || payload === null) {