authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-28 22:58:59-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-01 20:06:21-08:00
log505b4d0bc3581b2bf1eb82db1d3768e15c0fe526
tree681cb938801cdb955f3aa4585aabba8ff855886a
parent343c0a94729b7d8040dd4a0fe54a8c6fdee6eeef

Resolve persisted attachment paths across server pipelines

- add shared attachment path normalization and resolution helpers - reuse persisted image files for turn start and branch name generation - cover persisted attachment flow in text generation and reactor tests

7 files changed, 354 insertions(+), 44 deletions(-)

apps/server/src/attachmentPaths.ts created+54
...@@ -0,0 +1,54 @@
1import path from "node:path";
2
3import { ATTACHMENTS_ROUTE_PREFIX } from "./projectFaviconRoute.ts";
4
5export function normalizeAttachmentRelativePath(rawRelativePath: string): string | null {
6 const normalized = path.normalize(rawRelativePath).replace(/^[/\\]+/, "");
7 if (
8 normalized.length === 0 ||
9 normalized.startsWith("..") ||
10 normalized.includes("\0")
11 ) {
12 return null;
13 }
14 return normalized.replace(/\\/g, "/");
15}
16
17export function attachmentRouteToRelativePath(dataUrl: string): string | null {
18 const prefix = `${ATTACHMENTS_ROUTE_PREFIX}/`;
19 if (!dataUrl.startsWith(prefix)) {
20 return null;
21 }
22 return normalizeAttachmentRelativePath(dataUrl.slice(prefix.length));
23}
24
25export function resolveAttachmentRelativePath(input: {
26 readonly stateDir: string;
27 readonly relativePath: string;
28}): string | null {
29 const normalizedRelativePath = normalizeAttachmentRelativePath(input.relativePath);
30 if (!normalizedRelativePath) {
31 return null;
32 }
33
34 const attachmentsRoot = path.resolve(path.join(input.stateDir, "attachments"));
35 const filePath = path.resolve(path.join(attachmentsRoot, normalizedRelativePath));
36 if (!filePath.startsWith(`${attachmentsRoot}${path.sep}`)) {
37 return null;
38 }
39 return filePath;
40}
41
42export function resolveAttachmentRoutePath(input: {
43 readonly stateDir: string;
44 readonly dataUrl: string;
45}): string | null {
46 const relativePath = attachmentRouteToRelativePath(input.dataUrl);
47 if (!relativePath) {
48 return null;
49 }
50 return resolveAttachmentRelativePath({
51 stateDir: input.stateDir,
52 relativePath,
53 });
54}
apps/server/src/git/Layers/CodexTextGeneration.test.ts+63
...@@ -3,10 +3,28 @@ import { it } from "@effect/vitest";...@@ -3,10 +3,28 @@ import { it } from "@effect/vitest";
3import { Effect, FileSystem, Layer, Path } from "effect";3import { Effect, FileSystem, Layer, Path } from "effect";
4import { expect } from "vitest";4import { expect } from "vitest";
55
6import { type ServerConfigShape, ServerConfig } from "../../config.ts";
6import { CodexTextGenerationLive } from "./CodexTextGeneration.ts";7import { CodexTextGenerationLive } from "./CodexTextGeneration.ts";
7import { TextGenerationError } from "../Errors.ts";8import { TextGenerationError } from "../Errors.ts";
8import { TextGeneration } from "../Services/TextGeneration.ts";9import { TextGeneration } from "../Services/TextGeneration.ts";
910
11function makeTestServerConfig(stateDir: string): ServerConfigShape {
12 return {
13 mode: "web",
14 port: 0,
15 host: undefined,
16 cwd: process.cwd(),
17 keybindingsConfigPath: "",
18 stateDir,
19 staticDir: undefined,
20 devUrl: undefined,
21 noBrowser: true,
22 authToken: undefined,
23 autoBootstrapProjectFromCwd: false,
24 logWebSocketEvents: false,
25 };
26}
27
10function makeFakeCodexBinary(dir: string) {28function makeFakeCodexBinary(dir: string) {
11 return Effect.gen(function* () {29 return Effect.gen(function* () {
12 const fs = yield* FileSystem.FileSystem;30 const fs = yield* FileSystem.FileSystem;
...@@ -311,6 +329,51 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {...@@ -311,6 +329,51 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
311 ),329 ),
312 );330 );
313331
332 it.effect("resolves persisted attachment URLs to files for codex image inputs", () =>
333 withFakeCodexEnv(
334 {
335 output: JSON.stringify({
336 branch: "fix/ui-regression",
337 }),
338 requireImage: true,
339 },
340 Effect.gen(function* () {
341 const fs = yield* FileSystem.FileSystem;
342 const path = yield* Path.Path;
343 const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-state-textgen-" });
344 const imagePath = path.join(
345 stateDir,
346 "attachments",
347 "thread-1",
348 "message-1-0.png",
349 );
350 yield* fs.makeDirectory(path.join(stateDir, "attachments", "thread-1"), {
351 recursive: true,
352 });
353 yield* fs.writeFile(imagePath, Buffer.from("hello"));
354
355 const textGeneration = yield* TextGeneration;
356 const generated = yield* textGeneration
357 .generateBranchName({
358 cwd: process.cwd(),
359 message: "Fix layout bug from screenshot.",
360 attachments: [
361 {
362 type: "image",
363 name: "bug.png",
364 mimeType: "image/png",
365 sizeBytes: 5,
366 dataUrl: "/attachments/thread-1/message-1-0.png",
367 },
368 ],
369 })
370 .pipe(Effect.provideService(ServerConfig, makeTestServerConfig(stateDir)));
371
372 expect(generated.branch).toBe("fix/ui-regression");
373 }),
374 ),
375 );
376
314 it.effect("returns null branch when codex returns the wrong object shape", () =>377 it.effect("returns null branch when codex returns the wrong object shape", () =>
315 withFakeCodexEnv(378 withFakeCodexEnv(
316 {379 {
apps/server/src/git/Layers/CodexTextGeneration.ts+35-14
...@@ -3,6 +3,8 @@ import { randomUUID } from "node:crypto";...@@ -3,6 +3,8 @@ import { randomUUID } from "node:crypto";
3import { Effect, FileSystem, Layer, Option, Path, Schema, Stream } from "effect";3import { Effect, FileSystem, Layer, Option, Path, Schema, Stream } from "effect";
4import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";4import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
55
6import { resolveAttachmentRoutePath } from "../../attachmentPaths.ts";
7import { ServerConfig } from "../../config.ts";
6import { TextGenerationError } from "../Errors.ts";8import { TextGenerationError } from "../Errors.ts";
7import { inferImageExtension, parseBase64DataUrl } from "../../imageMime.ts";9import { inferImageExtension, parseBase64DataUrl } from "../../imageMime.ts";
8import {10import {
...@@ -245,6 +247,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {...@@ -245,6 +247,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {
245 if (!attachments || attachments.length === 0) {247 if (!attachments || attachments.length === 0) {
246 return [];248 return [];
247 }249 }
250 const serverConfig = yield* Effect.serviceOption(ServerConfig);
248251
249 const imagePaths: string[] = [];252 const imagePaths: string[] = [];
250 for (const [index, attachment] of attachments.entries()) {253 for (const [index, attachment] of attachments.entries()) {
...@@ -253,26 +256,44 @@ const makeCodexTextGeneration = Effect.gen(function* () {...@@ -253,26 +256,44 @@ const makeCodexTextGeneration = Effect.gen(function* () {
253 }256 }
254257
255 const parsed = parseBase64DataUrl(attachment.dataUrl);258 const parsed = parseBase64DataUrl(attachment.dataUrl);
256 if (!parsed || !parsed.mimeType.startsWith("image/")) {259 if (parsed && parsed.mimeType.startsWith("image/")) {
260 const bytes = Buffer.from(parsed.base64, "base64");
261 if (bytes.byteLength === 0) {
262 continue;
263 }
264
265 const extension = inferImageExtension({
266 mimeType: parsed.mimeType,
267 fileName: attachment.name,
268 });
269 const imagePath = yield* writeTempBinaryFile(
270 operation,
271 `codex-image-${index}`,
272 bytes,
273 extension,
274 );
275 imagePaths.push(imagePath);
257 continue;276 continue;
258 }277 }
259278
260 const bytes = Buffer.from(parsed.base64, "base64");279 const persistedPath =
261 if (bytes.byteLength === 0) {280 Option.isSome(serverConfig)
281 ? resolveAttachmentRoutePath({
282 stateDir: serverConfig.value.stateDir,
283 dataUrl: attachment.dataUrl,
284 })
285 : null;
286 const localPath = persistedPath ?? attachment.dataUrl;
287 if (!path.isAbsolute(localPath)) {
262 continue;288 continue;
263 }289 }
264290 const fileInfo = yield* fileSystem.stat(localPath).pipe(
265 const extension = inferImageExtension({291 Effect.catch(() => Effect.succeed(null)),
266 mimeType: parsed.mimeType,
267 fileName: attachment.name,
268 });
269 const imagePath = yield* writeTempBinaryFile(
270 operation,
271 `codex-image-${index}`,
272 bytes,
273 extension,
274 );292 );
275 imagePaths.push(imagePath);293 if (!fileInfo || fileInfo.type !== "File") {
294 continue;
295 }
296 imagePaths.push(localPath);
276 }297 }
277 return imagePaths;298 return imagePaths;
278 });299 });
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+3-16
...@@ -37,9 +37,8 @@ import {...@@ -37,9 +37,8 @@ import {
37 OrchestrationProjectionPipeline,37 OrchestrationProjectionPipeline,
38 type OrchestrationProjectionPipelineShape,38 type OrchestrationProjectionPipelineShape,
39} from "../Services/ProjectionPipeline.ts";39} from "../Services/ProjectionPipeline.ts";
40import {40import { attachmentRouteToRelativePath } from "../../attachmentPaths.ts";
41 ATTACHMENTS_ROUTE_PREFIX,41import { ATTACHMENTS_ROUTE_PREFIX } from "../../projectFaviconRoute.ts";
42} from "../../projectFaviconRoute.ts";
43import { inferImageExtension, parseBase64DataUrl } from "../../imageMime.ts";42import { inferImageExtension, parseBase64DataUrl } from "../../imageMime.ts";
4443
45export const ORCHESTRATION_PROJECTOR_NAMES = {44export const ORCHESTRATION_PROJECTOR_NAMES = {
...@@ -262,18 +261,6 @@ function retainProjectionActivitiesAfterRevert(...@@ -262,18 +261,6 @@ function retainProjectionActivitiesAfterRevert(
262 );261 );
263}262}
264263
265function toAttachmentRelativePath(dataUrl: string): string | null {
266 const prefix = `${ATTACHMENTS_ROUTE_PREFIX}/`;
267 if (!dataUrl.startsWith(prefix)) {
268 return null;
269 }
270 const relativePath = dataUrl.slice(prefix.length).replace(/^[/\\]+/, "");
271 if (relativePath.length === 0 || relativePath.startsWith("..")) {
272 return null;
273 }
274 return relativePath.replace(/\\/g, "/");
275}
276
277function collectThreadAttachmentRelativePaths(264function collectThreadAttachmentRelativePaths(
278 threadId: string,265 threadId: string,
279 messages: ReadonlyArray<ProjectionThreadMessage>,266 messages: ReadonlyArray<ProjectionThreadMessage>,
...@@ -289,7 +276,7 @@ function collectThreadAttachmentRelativePaths(...@@ -289,7 +276,7 @@ function collectThreadAttachmentRelativePaths(
289 if (attachment.type !== "image") {276 if (attachment.type !== "image") {
290 continue;277 continue;
291 }278 }
292 const relativePath = toAttachmentRelativePath(attachment.dataUrl);279 const relativePath = attachmentRouteToRelativePath(attachment.dataUrl);
293 if (!relativePath || !relativePath.startsWith(threadPrefix)) {280 if (!relativePath || !relativePath.startsWith(threadPrefix)) {
294 continue;281 continue;
295 }282 }
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+109-2
...@@ -1,3 +1,7 @@...@@ -1,3 +1,7 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
1import type { ProviderRuntimeEvent } from "@t3tools/contracts";5import type { ProviderRuntimeEvent } from "@t3tools/contracts";
2import {6import {
3 ApprovalRequestId,7 ApprovalRequestId,
...@@ -13,6 +17,7 @@ import {...@@ -13,6 +17,7 @@ import {
13import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect";17import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect";
14import { afterEach, describe, expect, it, vi } from "vitest";18import { afterEach, describe, expect, it, vi } from "vitest";
1519
20import { type ServerConfigShape, ServerConfig } from "../../config.ts";
16import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";21import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
17import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";22import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
18import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";23import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
...@@ -36,6 +41,23 @@ const asApprovalRequestId = (value: string): ApprovalRequestId =>...@@ -36,6 +41,23 @@ const asApprovalRequestId = (value: string): ApprovalRequestId =>
36const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);41const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);
37const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);42const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
3843
44function makeTestServerConfig(stateDir: string): ServerConfigShape {
45 return {
46 mode: "web",
47 port: 0,
48 host: undefined,
49 cwd: process.cwd(),
50 keybindingsConfigPath: "",
51 stateDir,
52 staticDir: undefined,
53 devUrl: undefined,
54 noBrowser: true,
55 authToken: undefined,
56 autoBootstrapProjectFromCwd: false,
57 logWebSocketEvents: false,
58 };
59}
60
39async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {61async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
40 const deadline = Date.now() + timeoutMs;62 const deadline = Date.now() + timeoutMs;
41 const poll = async (): Promise<void> => {63 const poll = async (): Promise<void> => {
...@@ -70,7 +92,7 @@ describe("ProviderCommandReactor", () => {...@@ -70,7 +92,7 @@ describe("ProviderCommandReactor", () => {
70 runtime = null;92 runtime = null;
71 });93 });
7294
73 async function createHarness() {95 async function createHarness(input?: { readonly stateDir?: string }) {
74 const now = new Date().toISOString();96 const now = new Date().toISOString();
75 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());97 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
76 let nextSessionIndex = 1;98 let nextSessionIndex = 1;
...@@ -124,7 +146,7 @@ describe("ProviderCommandReactor", () => {...@@ -124,7 +146,7 @@ describe("ProviderCommandReactor", () => {
124 Layer.provide(OrchestrationCommandReceiptRepositoryLive),146 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
125 Layer.provide(SqlitePersistenceMemory),147 Layer.provide(SqlitePersistenceMemory),
126 );148 );
127 const layer = ProviderCommandReactorLive.pipe(149 const liveLayer = ProviderCommandReactorLive.pipe(
128 Layer.provideMerge(orchestrationLayer),150 Layer.provideMerge(orchestrationLayer),
129 Layer.provideMerge(Layer.succeed(ProviderService, service)),151 Layer.provideMerge(Layer.succeed(ProviderService, service)),
130 Layer.provideMerge(Layer.succeed(GitCore, { renameBranch } as unknown as GitCoreShape)),152 Layer.provideMerge(Layer.succeed(GitCore, { renameBranch } as unknown as GitCoreShape)),
...@@ -135,6 +157,14 @@ describe("ProviderCommandReactor", () => {...@@ -135,6 +157,14 @@ describe("ProviderCommandReactor", () => {
135 ),157 ),
136 ),158 ),
137 );159 );
160 const layer =
161 input?.stateDir !== undefined
162 ? liveLayer.pipe(
163 Layer.provideMerge(
164 Layer.succeed(ServerConfig, makeTestServerConfig(input.stateDir)),
165 ),
166 )
167 : liveLayer;
138 runtime = ManagedRuntime.make(layer);168 runtime = ManagedRuntime.make(layer);
139 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));169 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));
140 const reactor = await runtime.runPromise(Effect.service(ProviderCommandReactor));170 const reactor = await runtime.runPromise(Effect.service(ProviderCommandReactor));
...@@ -285,6 +315,83 @@ describe("ProviderCommandReactor", () => {...@@ -285,6 +315,83 @@ describe("ProviderCommandReactor", () => {
285 });315 });
286 });316 });
287317
318 it("reuses persisted attachment files for branch generation and turn start", async () => {
319 const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-attachments-"));
320 const harness = await createHarness({ stateDir });
321 const now = new Date().toISOString();
322
323 await Effect.runPromise(
324 harness.engine.dispatch({
325 type: "thread.meta.update",
326 commandId: CommandId.makeUnsafe("cmd-thread-meta-set-temp-branch-persisted"),
327 threadId: ThreadId.makeUnsafe("thread-1"),
328 branch: "t3code/abcdef12",
329 worktreePath: "/tmp/provider-project/.t3/worktrees/t3code-abcdef12",
330 }),
331 );
332
333 await Effect.runPromise(
334 harness.engine.dispatch({
335 type: "thread.turn.start",
336 commandId: CommandId.makeUnsafe("cmd-turn-start-persisted-attachments"),
337 threadId: ThreadId.makeUnsafe("thread-1"),
338 message: {
339 messageId: asMessageId("user-message-persisted-attachments"),
340 role: "user",
341 text: "Fix visual bug from screenshot",
342 attachments: [
343 {
344 type: "image",
345 name: "bug.png",
346 mimeType: "image/png",
347 sizeBytes: 5,
348 dataUrl: "data:image/png;base64,SGVsbG8=",
349 },
350 ],
351 },
352 approvalPolicy: "on-request",
353 sandboxMode: "workspace-write",
354 createdAt: now,
355 }),
356 );
357
358 await waitFor(() => harness.generateBranchName.mock.calls.length === 1);
359 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
360
361 const expectedAttachmentPath = path.join(
362 stateDir,
363 "attachments",
364 "thread-1",
365 "user-message-persisted-attachments-0.png",
366 );
367
368 expect(harness.generateBranchName.mock.calls[0]?.[0]).toEqual({
369 cwd: "/tmp/provider-project/.t3/worktrees/t3code-abcdef12",
370 message: "Fix visual bug from screenshot",
371 attachments: [
372 {
373 type: "image",
374 name: "bug.png",
375 mimeType: "image/png",
376 sizeBytes: 5,
377 dataUrl: expectedAttachmentPath,
378 },
379 ],
380 });
381 expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({
382 attachments: [
383 {
384 type: "image",
385 name: "bug.png",
386 mimeType: "image/png",
387 sizeBytes: 5,
388 dataUrl: expectedAttachmentPath,
389 },
390 ],
391 });
392 expect(fs.existsSync(expectedAttachmentPath)).toBe(true);
393 });
394
288 it("skips worktree branch generation after the first user turn", async () => {395 it("skips worktree branch generation after the first user turn", async () => {
289 const harness = await createHarness();396 const harness = await createHarness();
290 const now = new Date().toISOString();397 const now = new Date().toISOString();
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+82-2
...@@ -1,3 +1,5 @@...@@ -1,3 +1,5 @@
1import fs from "node:fs";
2
1import {3import {
2 type ChatAttachment,4 type ChatAttachment,
3 CommandId,5 CommandId,
...@@ -15,8 +17,11 @@ import {...@@ -15,8 +17,11 @@ import {
15import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Stream } from "effect";17import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Stream } from "effect";
1618
17import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";19import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";
20import { resolveAttachmentRelativePath, resolveAttachmentRoutePath } from "../../attachmentPaths.ts";
21import { ServerConfig } from "../../config.ts";
18import { GitCore } from "../../git/Services/GitCore.ts";22import { GitCore } from "../../git/Services/GitCore.ts";
19import { TextGeneration } from "../../git/Services/TextGeneration.ts";23import { TextGeneration } from "../../git/Services/TextGeneration.ts";
24import { inferImageExtension, parseBase64DataUrl } from "../../imageMime.ts";
20import { ProviderService } from "../../provider/Services/ProviderService.ts";25import { ProviderService } from "../../provider/Services/ProviderService.ts";
21import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";26import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
22import {27import {
...@@ -103,6 +108,7 @@ const make = Effect.gen(function* () {...@@ -103,6 +108,7 @@ const make = Effect.gen(function* () {
103 const providerService = yield* ProviderService;108 const providerService = yield* ProviderService;
104 const git = yield* GitCore;109 const git = yield* GitCore;
105 const textGeneration = yield* TextGeneration;110 const textGeneration = yield* TextGeneration;
111 const serverConfig = yield* Effect.serviceOption(ServerConfig);
106 const handledTurnStartKeys = yield* Cache.make<string, true>({112 const handledTurnStartKeys = yield* Cache.make<string, true>({
107 capacity: HANDLED_TURN_START_KEY_MAX,113 capacity: HANDLED_TURN_START_KEY_MAX,
108 timeToLive: HANDLED_TURN_START_KEY_TTL,114 timeToLive: HANDLED_TURN_START_KEY_TTL,
...@@ -280,6 +286,74 @@ const make = Effect.gen(function* () {...@@ -280,6 +286,74 @@ const make = Effect.gen(function* () {
280 });286 });
281 });287 });
282288
289 const resolvePersistedAttachments = Effect.fnUntraced(function* (
290 input: {
291 readonly threadId: ThreadId;
292 readonly messageId: string;
293 readonly attachments: ReadonlyArray<ChatAttachment> | undefined;
294 },
295 ) {
296 if (!input.attachments || input.attachments.length === 0) {
297 return input.attachments ?? [];
298 }
299
300 if (Option.isNone(serverConfig)) {
301 return input.attachments;
302 }
303 const threadSegment = encodeURIComponent(input.threadId);
304 const messageSegment = encodeURIComponent(input.messageId);
305
306 return yield* Effect.forEach(
307 Array.from(input.attachments.entries()),
308 ([index, attachment]) =>
309 Effect.gen(function* () {
310 if (attachment.type !== "image") {
311 return attachment;
312 }
313
314 const resolvedRoutePath = resolveAttachmentRoutePath({
315 stateDir: serverConfig.value.stateDir,
316 dataUrl: attachment.dataUrl,
317 });
318 const resolvedMaterializedPath =
319 resolvedRoutePath ??
320 (() => {
321 const parsed = parseBase64DataUrl(attachment.dataUrl);
322 if (!parsed || !parsed.mimeType.startsWith("image/")) {
323 return null;
324 }
325 const extension = inferImageExtension({
326 mimeType: parsed.mimeType,
327 fileName: attachment.name,
328 });
329 return resolveAttachmentRelativePath({
330 stateDir: serverConfig.value.stateDir,
331 relativePath: `${threadSegment}/${messageSegment}-${index}${extension}`,
332 });
333 })();
334
335 if (!resolvedMaterializedPath) {
336 return attachment;
337 }
338
339 const isFile = yield* Effect.sync(() => {
340 try {
341 return fs.statSync(resolvedMaterializedPath).isFile();
342 } catch {
343 return false;
344 }
345 });
346 if (!isFile) {
347 return attachment;
348 }
349 return {
350 ...attachment,
351 dataUrl: resolvedMaterializedPath,
352 } satisfies ChatAttachment;
353 }),
354 );
355 });
356
283 const maybeGenerateAndRenameWorktreeBranchForFirstTurn = Effect.fnUntraced(function* (input: {357 const maybeGenerateAndRenameWorktreeBranchForFirstTurn = Effect.fnUntraced(function* (input: {
284 readonly threadId: ThreadId;358 readonly threadId: ThreadId;
285 readonly branch: string | null;359 readonly branch: string | null;
...@@ -381,19 +455,25 @@ const make = Effect.gen(function* () {...@@ -381,19 +455,25 @@ const make = Effect.gen(function* () {
381 return;455 return;
382 }456 }
383457
458 const resolvedAttachments = yield* resolvePersistedAttachments({
459 threadId: event.payload.threadId,
460 messageId: message.id,
461 attachments: message.attachments,
462 });
463
384 yield* maybeGenerateAndRenameWorktreeBranchForFirstTurn({464 yield* maybeGenerateAndRenameWorktreeBranchForFirstTurn({
385 threadId: event.payload.threadId,465 threadId: event.payload.threadId,
386 branch: thread.branch,466 branch: thread.branch,
387 worktreePath: thread.worktreePath,467 worktreePath: thread.worktreePath,
388 messageId: message.id,468 messageId: message.id,
389 messageText: message.text,469 messageText: message.text,
390 ...(message.attachments !== undefined ? { attachments: message.attachments } : {}),470 ...(message.attachments !== undefined ? { attachments: resolvedAttachments } : {}),
391 });471 });
392472
393 yield* sendTurnForThread({473 yield* sendTurnForThread({
394 threadId: event.payload.threadId,474 threadId: event.payload.threadId,
395 messageText: message.text,475 messageText: message.text,
396 ...(message.attachments !== undefined ? { attachments: message.attachments } : {}),476 ...(message.attachments !== undefined ? { attachments: resolvedAttachments } : {}),
397 ...(event.payload.model !== undefined ? { model: event.payload.model } : {}),477 ...(event.payload.model !== undefined ? { model: event.payload.model } : {}),
398 ...(event.payload.effort !== undefined ? { effort: event.payload.effort } : {}),478 ...(event.payload.effort !== undefined ? { effort: event.payload.effort } : {}),
399 approvalPolicy: event.payload.approvalPolicy,479 approvalPolicy: event.payload.approvalPolicy,
apps/server/src/wsServer.ts+8-10
...@@ -55,6 +55,7 @@ import { Open } from "./open";...@@ -55,6 +55,7 @@ import { Open } from "./open";
55import { ServerConfig } from "./config";55import { ServerConfig } from "./config";
56import { GitCore } from "./git/Services/GitCore.ts";56import { GitCore } from "./git/Services/GitCore.ts";
57import { ATTACHMENTS_ROUTE_PREFIX, tryHandleProjectFaviconRequest } from "./projectFaviconRoute";57import { ATTACHMENTS_ROUTE_PREFIX, tryHandleProjectFaviconRequest } from "./projectFaviconRoute";
58import { normalizeAttachmentRelativePath, resolveAttachmentRelativePath } from "./attachmentPaths";
5859
59/**60/**
60 * ServerShape - Service API for server lifecycle control.61 * ServerShape - Service API for server lifecycle control.
...@@ -249,21 +250,18 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<...@@ -249,21 +250,18 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
249 }250 }
250251
251 if (url.pathname.startsWith(ATTACHMENTS_ROUTE_PREFIX)) {252 if (url.pathname.startsWith(ATTACHMENTS_ROUTE_PREFIX)) {
252 const attachmentsRoot = path.resolve(path.join(serverConfig.stateDir, "attachments"));
253 const rawRelativePath = url.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length);253 const rawRelativePath = url.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length);
254 const normalizedRelativePath = path.normalize(rawRelativePath).replace(/^[/\\]+/, "");254 const normalizedRelativePath = normalizeAttachmentRelativePath(rawRelativePath);
255255 if (!normalizedRelativePath) {
256 if (
257 normalizedRelativePath.length === 0 ||
258 normalizedRelativePath.startsWith("..") ||
259 normalizedRelativePath.includes("\0")
260 ) {
261 respond(400, { "Content-Type": "text/plain" }, "Invalid attachment path");256 respond(400, { "Content-Type": "text/plain" }, "Invalid attachment path");
262 return;257 return;
263 }258 }
264259
265 const filePath = path.resolve(path.join(attachmentsRoot, normalizedRelativePath));260 const filePath = resolveAttachmentRelativePath({
266 if (!filePath.startsWith(`${attachmentsRoot}${path.sep}`)) {261 stateDir: serverConfig.stateDir,
262 relativePath: normalizedRelativePath,
263 });
264 if (!filePath) {
267 respond(400, { "Content-Type": "text/plain" }, "Invalid attachment path");265 respond(400, { "Content-Type": "text/plain" }, "Invalid attachment path");
268 return;266 return;
269 }267 }