| author | |
| committer | |
| log | 505b4d0bc3581b2bf1eb82db1d3768e15c0fe526 |
| tree | 681cb938801cdb955f3aa4585aabba8ff855886a |
| parent | 343c0a94729b7d8040dd4a0fe54a8c6fdee6eeef |
- 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 tests7 files changed, 354 insertions(+), 44 deletions(-)
apps/server/src/attachmentPaths.ts created+54| ... | ... | @@ -0,0 +1,54 @@ |
| 1 | import path from "node:path"; | |
| 2 | ||
| 3 | import { ATTACHMENTS_ROUTE_PREFIX } from "./projectFaviconRoute.ts"; | |
| 4 | ||
| 5 | export 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 | ||
| 17 | export 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 | ||
| 25 | export 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 | ||
| 42 | export 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 | 3 | import { Effect, FileSystem, Layer, Path } from "effect"; |
| 4 | 4 | import { expect } from "vitest"; |
| 5 | 5 | |
| 6 | import { type ServerConfigShape, ServerConfig } from "../../config.ts"; | |
| 6 | 7 | import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; |
| 7 | 8 | import { TextGenerationError } from "../Errors.ts"; |
| 8 | 9 | import { TextGeneration } from "../Services/TextGeneration.ts"; |
| 9 | 10 | |
| 11 | function 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 | ||
| 10 | 28 | function makeFakeCodexBinary(dir: string) { |
| 11 | 29 | return Effect.gen(function* () { |
| 12 | 30 | const fs = yield* FileSystem.FileSystem; |
| ... | ... | @@ -311,6 +329,51 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { |
| 311 | 329 | ), |
| 312 | 330 | ); |
| 313 | 331 | |
| 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 | 377 | it.effect("returns null branch when codex returns the wrong object shape", () => |
| 315 | 378 | withFakeCodexEnv( |
| 316 | 379 | { |
apps/server/src/git/Layers/CodexTextGeneration.ts+35-14| ... | ... | @@ -3,6 +3,8 @@ import { randomUUID } from "node:crypto"; |
| 3 | 3 | import { Effect, FileSystem, Layer, Option, Path, Schema, Stream } from "effect"; |
| 4 | 4 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 5 | 5 | |
| 6 | import { resolveAttachmentRoutePath } from "../../attachmentPaths.ts"; | |
| 7 | import { ServerConfig } from "../../config.ts"; | |
| 6 | 8 | import { TextGenerationError } from "../Errors.ts"; |
| 7 | 9 | import { inferImageExtension, parseBase64DataUrl } from "../../imageMime.ts"; |
| 8 | 10 | import { |
| ... | ... | @@ -245,6 +247,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 245 | 247 | if (!attachments || attachments.length === 0) { |
| 246 | 248 | return []; |
| 247 | 249 | } |
| 250 | const serverConfig = yield* Effect.serviceOption(ServerConfig); | |
| 248 | 251 | |
| 249 | 252 | const imagePaths: string[] = []; |
| 250 | 253 | for (const [index, attachment] of attachments.entries()) { |
| ... | ... | @@ -253,26 +256,44 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 253 | 256 | } |
| 254 | 257 | |
| 255 | 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 | 276 | continue; |
| 258 | 277 | } |
| 259 | 278 | |
| 260 | const bytes = Buffer.from(parsed.base64, "base64"); | |
| 261 | if (bytes.byteLength === 0) { | |
| 279 | const persistedPath = | |
| 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 | 288 | continue; |
| 263 | 289 | } |
| 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, | |
| 290 | const fileInfo = yield* fileSystem.stat(localPath).pipe( | |
| 291 | Effect.catch(() => Effect.succeed(null)), | |
| 274 | 292 | ); |
| 275 | imagePaths.push(imagePath); | |
| 293 | if (!fileInfo || fileInfo.type !== "File") { | |
| 294 | continue; | |
| 295 | } | |
| 296 | imagePaths.push(localPath); | |
| 276 | 297 | } |
| 277 | 298 | return imagePaths; |
| 278 | 299 | }); |
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+3-16| ... | ... | @@ -37,9 +37,8 @@ import { |
| 37 | 37 | OrchestrationProjectionPipeline, |
| 38 | 38 | type OrchestrationProjectionPipelineShape, |
| 39 | 39 | } from "../Services/ProjectionPipeline.ts"; |
| 40 | import { | |
| 41 | ATTACHMENTS_ROUTE_PREFIX, | |
| 42 | } from "../../projectFaviconRoute.ts"; | |
| 40 | import { attachmentRouteToRelativePath } from "../../attachmentPaths.ts"; | |
| 41 | import { ATTACHMENTS_ROUTE_PREFIX } from "../../projectFaviconRoute.ts"; | |
| 43 | 42 | import { inferImageExtension, parseBase64DataUrl } from "../../imageMime.ts"; |
| 44 | 43 | |
| 45 | 44 | export const ORCHESTRATION_PROJECTOR_NAMES = { |
| ... | ... | @@ -262,18 +261,6 @@ function retainProjectionActivitiesAfterRevert( |
| 262 | 261 | ); |
| 263 | 262 | } |
| 264 | 263 | |
| 265 | function 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 | ||
| 277 | 264 | function collectThreadAttachmentRelativePaths( |
| 278 | 265 | threadId: string, |
| 279 | 266 | messages: ReadonlyArray<ProjectionThreadMessage>, |
| ... | ... | @@ -289,7 +276,7 @@ function collectThreadAttachmentRelativePaths( |
| 289 | 276 | if (attachment.type !== "image") { |
| 290 | 277 | continue; |
| 291 | 278 | } |
| 292 | const relativePath = toAttachmentRelativePath(attachment.dataUrl); | |
| 279 | const relativePath = attachmentRouteToRelativePath(attachment.dataUrl); | |
| 293 | 280 | if (!relativePath || !relativePath.startsWith(threadPrefix)) { |
| 294 | 281 | continue; |
| 295 | 282 | } |
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+109-2| ... | ... | @@ -1,3 +1,7 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 1 | 5 | import type { ProviderRuntimeEvent } from "@t3tools/contracts"; |
| 2 | 6 | import { |
| 3 | 7 | ApprovalRequestId, |
| ... | ... | @@ -13,6 +17,7 @@ import { |
| 13 | 17 | import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect"; |
| 14 | 18 | import { afterEach, describe, expect, it, vi } from "vitest"; |
| 15 | 19 | |
| 20 | import { type ServerConfigShape, ServerConfig } from "../../config.ts"; | |
| 16 | 21 | import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; |
| 17 | 22 | import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; |
| 18 | 23 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; |
| ... | ... | @@ -36,6 +41,23 @@ const asApprovalRequestId = (value: string): ApprovalRequestId => |
| 36 | 41 | const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); |
| 37 | 42 | const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); |
| 38 | 43 | |
| 44 | function 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 | ||
| 39 | 61 | async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> { |
| 40 | 62 | const deadline = Date.now() + timeoutMs; |
| 41 | 63 | const poll = async (): Promise<void> => { |
| ... | ... | @@ -70,7 +92,7 @@ describe("ProviderCommandReactor", () => { |
| 70 | 92 | runtime = null; |
| 71 | 93 | }); |
| 72 | 94 | |
| 73 | async function createHarness() { | |
| 95 | async function createHarness(input?: { readonly stateDir?: string }) { | |
| 74 | 96 | const now = new Date().toISOString(); |
| 75 | 97 | const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>()); |
| 76 | 98 | let nextSessionIndex = 1; |
| ... | ... | @@ -124,7 +146,7 @@ describe("ProviderCommandReactor", () => { |
| 124 | 146 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 125 | 147 | Layer.provide(SqlitePersistenceMemory), |
| 126 | 148 | ); |
| 127 | const layer = ProviderCommandReactorLive.pipe( | |
| 149 | const liveLayer = ProviderCommandReactorLive.pipe( | |
| 128 | 150 | Layer.provideMerge(orchestrationLayer), |
| 129 | 151 | Layer.provideMerge(Layer.succeed(ProviderService, service)), |
| 130 | 152 | Layer.provideMerge(Layer.succeed(GitCore, { renameBranch } as unknown as GitCoreShape)), |
| ... | ... | @@ -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 | 168 | runtime = ManagedRuntime.make(layer); |
| 139 | 169 | const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); |
| 140 | 170 | const reactor = await runtime.runPromise(Effect.service(ProviderCommandReactor)); |
| ... | ... | @@ -285,6 +315,83 @@ describe("ProviderCommandReactor", () => { |
| 285 | 315 | }); |
| 286 | 316 | }); |
| 287 | 317 | |
| 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 | 395 | it("skips worktree branch generation after the first user turn", async () => { |
| 289 | 396 | const harness = await createHarness(); |
| 290 | 397 | const now = new Date().toISOString(); |
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+82-2| ... | ... | @@ -1,3 +1,5 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | ||
| 1 | 3 | import { |
| 2 | 4 | type ChatAttachment, |
| 3 | 5 | CommandId, |
| ... | ... | @@ -15,8 +17,11 @@ import { |
| 15 | 17 | import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Stream } from "effect"; |
| 16 | 18 | |
| 17 | 19 | import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; |
| 20 | import { resolveAttachmentRelativePath, resolveAttachmentRoutePath } from "../../attachmentPaths.ts"; | |
| 21 | import { ServerConfig } from "../../config.ts"; | |
| 18 | 22 | import { GitCore } from "../../git/Services/GitCore.ts"; |
| 19 | 23 | import { TextGeneration } from "../../git/Services/TextGeneration.ts"; |
| 24 | import { inferImageExtension, parseBase64DataUrl } from "../../imageMime.ts"; | |
| 20 | 25 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; |
| 21 | 26 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; |
| 22 | 27 | import { |
| ... | ... | @@ -103,6 +108,7 @@ const make = Effect.gen(function* () { |
| 103 | 108 | const providerService = yield* ProviderService; |
| 104 | 109 | const git = yield* GitCore; |
| 105 | 110 | const textGeneration = yield* TextGeneration; |
| 111 | const serverConfig = yield* Effect.serviceOption(ServerConfig); | |
| 106 | 112 | const handledTurnStartKeys = yield* Cache.make<string, true>({ |
| 107 | 113 | capacity: HANDLED_TURN_START_KEY_MAX, |
| 108 | 114 | timeToLive: HANDLED_TURN_START_KEY_TTL, |
| ... | ... | @@ -280,6 +286,74 @@ const make = Effect.gen(function* () { |
| 280 | 286 | }); |
| 281 | 287 | }); |
| 282 | 288 | |
| 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 | 357 | const maybeGenerateAndRenameWorktreeBranchForFirstTurn = Effect.fnUntraced(function* (input: { |
| 284 | 358 | readonly threadId: ThreadId; |
| 285 | 359 | readonly branch: string | null; |
| ... | ... | @@ -381,19 +455,25 @@ const make = Effect.gen(function* () { |
| 381 | 455 | return; |
| 382 | 456 | } |
| 383 | 457 | |
| 458 | const resolvedAttachments = yield* resolvePersistedAttachments({ | |
| 459 | threadId: event.payload.threadId, | |
| 460 | messageId: message.id, | |
| 461 | attachments: message.attachments, | |
| 462 | }); | |
| 463 | ||
| 384 | 464 | yield* maybeGenerateAndRenameWorktreeBranchForFirstTurn({ |
| 385 | 465 | threadId: event.payload.threadId, |
| 386 | 466 | branch: thread.branch, |
| 387 | 467 | worktreePath: thread.worktreePath, |
| 388 | 468 | messageId: message.id, |
| 389 | 469 | messageText: message.text, |
| 390 | ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), | |
| 470 | ...(message.attachments !== undefined ? { attachments: resolvedAttachments } : {}), | |
| 391 | 471 | }); |
| 392 | 472 | |
| 393 | 473 | yield* sendTurnForThread({ |
| 394 | 474 | threadId: event.payload.threadId, |
| 395 | 475 | messageText: message.text, |
| 396 | ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), | |
| 476 | ...(message.attachments !== undefined ? { attachments: resolvedAttachments } : {}), | |
| 397 | 477 | ...(event.payload.model !== undefined ? { model: event.payload.model } : {}), |
| 398 | 478 | ...(event.payload.effort !== undefined ? { effort: event.payload.effort } : {}), |
| 399 | 479 | approvalPolicy: event.payload.approvalPolicy, |
apps/server/src/wsServer.ts+8-10| ... | ... | @@ -55,6 +55,7 @@ import { Open } from "./open"; |
| 55 | 55 | import { ServerConfig } from "./config"; |
| 56 | 56 | import { GitCore } from "./git/Services/GitCore.ts"; |
| 57 | 57 | import { ATTACHMENTS_ROUTE_PREFIX, tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; |
| 58 | import { normalizeAttachmentRelativePath, resolveAttachmentRelativePath } from "./attachmentPaths"; | |
| 58 | 59 | |
| 59 | 60 | /** |
| 60 | 61 | * ServerShape - Service API for server lifecycle control. |
| ... | ... | @@ -249,21 +250,18 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 249 | 250 | } |
| 250 | 251 | |
| 251 | 252 | if (url.pathname.startsWith(ATTACHMENTS_ROUTE_PREFIX)) { |
| 252 | const attachmentsRoot = path.resolve(path.join(serverConfig.stateDir, "attachments")); | |
| 253 | 253 | const rawRelativePath = url.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length); |
| 254 | const normalizedRelativePath = path.normalize(rawRelativePath).replace(/^[/\\]+/, ""); | |
| 255 | ||
| 256 | if ( | |
| 257 | normalizedRelativePath.length === 0 || | |
| 258 | normalizedRelativePath.startsWith("..") || | |
| 259 | normalizedRelativePath.includes("\0") | |
| 260 | ) { | |
| 254 | const normalizedRelativePath = normalizeAttachmentRelativePath(rawRelativePath); | |
| 255 | if (!normalizedRelativePath) { | |
| 261 | 256 | respond(400, { "Content-Type": "text/plain" }, "Invalid attachment path"); |
| 262 | 257 | return; |
| 263 | 258 | } |
| 264 | 259 | |
| 265 | const filePath = path.resolve(path.join(attachmentsRoot, normalizedRelativePath)); | |
| 266 | if (!filePath.startsWith(`${attachmentsRoot}${path.sep}`)) { | |
| 260 | const filePath = resolveAttachmentRelativePath({ | |
| 261 | stateDir: serverConfig.stateDir, | |
| 262 | relativePath: normalizedRelativePath, | |
| 263 | }); | |
| 264 | if (!filePath) { | |
| 267 | 265 | respond(400, { "Content-Type": "text/plain" }, "Invalid attachment path"); |
| 268 | 266 | return; |
| 269 | 267 | } |