Unified · Split
| @@ -349,6 +349,15 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { · 348 unmodified lines | ||
| 349 | 349 | }, |
| 350 | 350 | ], |
| 351 | 351 | }) |
| 352 | + .pipe( | |
| 353 | + Effect.tap(() => | |
| 354 | + fs.stat(imagePath).pipe( | |
| 355 | + Effect.map((fileInfo) => { | |
| 356 | + expect(fileInfo.type).toBe("File"); | |
| 357 | + }), | |
| 358 | + ), | |
| 359 | + ), | |
| 360 | + ) | |
| 352 | 361 | .pipe( |
| 353 | 362 | Effect.ensuring( |
| 354 | 363 | fs.remove(imagePath).pipe(Effect.catch(() => Effect.void)), |
| @@ -360,6 +369,54 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { · 5 unmodified lines | ||
| 360 | 369 | ), |
| 361 | 370 | ); |
| 362 | 371 | |
| 372 | + it.effect("ignores absolute non-route attachment paths for codex image inputs", () => | |
| 373 | + withFakeCodexEnv( | |
| 374 | + { | |
| 375 | + output: JSON.stringify({ | |
| 376 | + branch: "fix/ui-regression", | |
| 377 | + }), | |
| 378 | + requireImage: true, | |
| 379 | + }, | |
| 380 | + Effect.gen(function* () { | |
| 381 | + const fs = yield* FileSystem.FileSystem; | |
| 382 | + const path = yield* Path.Path; | |
| 383 | + const tempDir = yield* fs.makeTempDirectoryScoped({ | |
| 384 | + prefix: "t3code-codex-attachment-bypass-", | |
| 385 | + }); | |
| 386 | + const absoluteImagePath = path.join(tempDir, "outside.png"); | |
| 387 | + yield* fs.writeFile(absoluteImagePath, Buffer.from("hello")); | |
| 388 | + | |
| 389 | + const textGeneration = yield* TextGeneration; | |
| 390 | + const result = yield* textGeneration | |
| 391 | + .generateBranchName({ | |
| 392 | + cwd: process.cwd(), | |
| 393 | + message: "Fix layout bug from screenshot.", | |
| 394 | + attachments: [ | |
| 395 | + { | |
| 396 | + type: "image", | |
| 397 | + name: "outside.png", | |
| 398 | + mimeType: "image/png", | |
| 399 | + sizeBytes: 5, | |
| 400 | + dataUrl: absoluteImagePath, | |
| 401 | + }, | |
| 402 | + ], | |
| 403 | + }) | |
| 404 | + .pipe( | |
| 405 | + Effect.match({ | |
| 406 | + onFailure: (error) => ({ _tag: "Left" as const, left: error }), | |
| 407 | + onSuccess: (value) => ({ _tag: "Right" as const, right: value }), | |
| 408 | + }), | |
| 409 | + ); | |
| 410 | + | |
| 411 | + expect(result._tag).toBe("Left"); | |
| 412 | + if (result._tag === "Left") { | |
| 413 | + expect(result.left).toBeInstanceOf(TextGenerationError); | |
| 414 | + expect(result.left.message).toContain("missing --image input"); | |
| 415 | + } | |
| 416 | + }), | |
| 417 | + ), | |
| 418 | + ); | |
| 419 | + | |
| 363 | 420 | it.effect( |
| 364 | 421 | "fails with typed TextGenerationError when codex returns wrong branch payload shape", |
| 365 | 422 | () => |
| @@ -107,6 +107,11 @@ const makeCodexTextGeneration = Effect.gen(function* () { · 106 unmodified lines | ||
| 107 | 107 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 108 | 108 | const serverConfig = yield* Effect.service(ServerConfig); |
| 109 | 109 | |
| 110 | + type MaterializedImageAttachments = { | |
| 111 | + readonly imagePaths: ReadonlyArray<string>; | |
| 112 | + readonly tempImagePaths: ReadonlyArray<string>; | |
| 113 | + }; | |
| 114 | + | |
| 110 | 115 | const readStreamAsString = <E>( |
| 111 | 116 | operation: string, |
| 112 | 117 | stream: Stream.Stream<Uint8Array, E>, |
| @@ -176,10 +181,12 @@ const makeCodexTextGeneration = Effect.gen(function* () { · 63 unmodified lines | ||
| 176 | 181 | const materializeImageAttachments = ( |
| 177 | 182 | operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName", |
| 178 | 183 | attachments: BranchNameGenerationInput["attachments"], |
| 179 | − ): Effect.Effect<ReadonlyArray<string>, TextGenerationError> => | |
| 180 | − Effect.gen(function* () { | |
| 184 | + ): Effect.Effect<MaterializedImageAttachments, TextGenerationError> => { | |
| 185 | + const tempImagePaths: string[] = []; | |
| 186 | + | |
| 187 | + return Effect.gen(function* () { | |
| 181 | 188 | if (!attachments || attachments.length === 0) { |
| 182 | − return []; | |
| 189 | + return { imagePaths: [], tempImagePaths }; | |
| 183 | 190 | } |
| 184 | 191 | |
| 185 | 192 | const imagePaths: string[] = []; |
| @@ -206,6 +213,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { · 20 unmodified lines | ||
| 206 | 213 | extension, |
| 207 | 214 | ); |
| 208 | 215 | imagePaths.push(imagePath); |
| 216 | + tempImagePaths.push(imagePath); | |
| 209 | 217 | continue; |
| 210 | 218 | } |
| 211 | 219 | |
| @@ -213,20 +221,26 @@ const makeCodexTextGeneration = Effect.gen(function* () { · 1 unmodified line | ||
| 213 | 221 | stateDir: serverConfig.stateDir, |
| 214 | 222 | dataUrl: attachment.dataUrl, |
| 215 | 223 | }); |
| 216 | − const localPath = persistedPath ?? attachment.dataUrl; | |
| 217 | − if (!path.isAbsolute(localPath)) { | |
| 224 | + if (!persistedPath || !path.isAbsolute(persistedPath)) { | |
| 218 | 225 | continue; |
| 219 | 226 | } |
| 220 | 227 | const fileInfo = yield* fileSystem |
| 221 | − .stat(localPath) | |
| 228 | + .stat(persistedPath) | |
| 222 | 229 | .pipe(Effect.catch(() => Effect.succeed(null))); |
| 223 | 230 | if (!fileInfo || fileInfo.type !== "File") { |
| 224 | 231 | continue; |
| 225 | 232 | } |
| 226 | − imagePaths.push(localPath); | |
| 233 | + imagePaths.push(persistedPath); | |
| 227 | 234 | } |
| 228 | − return imagePaths; | |
| 229 | − }); | |
| 235 | + return { imagePaths, tempImagePaths }; | |
| 236 | + }).pipe( | |
| 237 | + Effect.tapError(() => | |
| 238 | + Effect.all(tempImagePaths.map((filePath) => safeUnlink(filePath)), { | |
| 239 | + concurrency: "unbounded", | |
| 240 | + }).pipe(Effect.asVoid), | |
| 241 | + ), | |
| 242 | + ); | |
| 243 | + }; | |
| 230 | 244 | |
| 231 | 245 | const runCodexJson = <S extends Schema.Top>({ |
| 232 | 246 | operation, |
| @@ -234,12 +248,14 @@ const makeCodexTextGeneration = Effect.gen(function* () { · 1 unmodified line | ||
| 234 | 248 | prompt, |
| 235 | 249 | outputSchemaJson, |
| 236 | 250 | imagePaths = [], |
| 251 | + cleanupPaths = [], | |
| 237 | 252 | }: { |
| 238 | 253 | operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName"; |
| 239 | 254 | cwd: string; |
| 240 | 255 | prompt: string; |
| 241 | 256 | outputSchemaJson: S; |
| 242 | 257 | imagePaths?: ReadonlyArray<string>; |
| 258 | + cleanupPaths?: ReadonlyArray<string>; | |
| 243 | 259 | }): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> => |
| 244 | 260 | Effect.gen(function* () { |
| 245 | 261 | const schemaPath = yield* writeTempFile( |
| @@ -313,7 +329,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { · 67 unmodified lines | ||
| 313 | 329 | }); |
| 314 | 330 | |
| 315 | 331 | const cleanup = Effect.all( |
| 316 | − [schemaPath, outputPath, ...imagePaths].map((filePath) => safeUnlink(filePath)), | |
| 332 | + [schemaPath, outputPath, ...cleanupPaths].map((filePath) => safeUnlink(filePath)), | |
| 317 | 333 | { |
| 318 | 334 | concurrency: "unbounded", |
| 319 | 335 | }, |
| @@ -438,7 +454,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { · 118 unmodified lines | ||
| 438 | 454 | |
| 439 | 455 | const generateBranchName: TextGenerationShape["generateBranchName"] = (input) => { |
| 440 | 456 | return Effect.gen(function* () { |
| 441 | − const imagePaths = yield* materializeImageAttachments( | |
| 457 | + const { imagePaths, tempImagePaths } = yield* materializeImageAttachments( | |
| 442 | 458 | "generateBranchName", |
| 443 | 459 | input.attachments, |
| 444 | 460 | ); |
| @@ -476,6 +492,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { · 31 unmodified lines | ||
| 476 | 492 | branch: Schema.String, |
| 477 | 493 | }), |
| 478 | 494 | imagePaths, |
| 495 | + cleanupPaths: tempImagePaths, | |
| 479 | 496 | }); |
| 480 | 497 | |
| 481 | 498 | return { |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | |
| 3 | −import { parseBase64DataUrl } from "./imageMime.ts"; | |
| 3 | +import { inferImageExtension, parseBase64DataUrl } from "./imageMime.ts"; | |
| 4 | 4 | |
| 5 | 5 | describe("imageMime", () => { |
| 6 | 6 | it("parses base64 data URL with mime type", () => { |
| @@ -24,4 +24,15 @@ describe("imageMime", () => { · 17 unmodified lines | ||
| 24 | 24 | it("rejects missing mime type", () => { |
| 25 | 25 | expect(parseBase64DataUrl("data:;base64,SGVsbG8=")).toBeNull(); |
| 26 | 26 | }); |
| 27 | + | |
| 28 | + it("parses base64 data URL with spaces in payload", () => { | |
| 29 | + expect(parseBase64DataUrl("data:image/png;base64,SGVs bG8=\n")).toEqual({ | |
| 30 | + mimeType: "image/png", | |
| 31 | + base64: "SGVsbG8=", | |
| 32 | + }); | |
| 33 | + }); | |
| 34 | + | |
| 35 | + it("does not read inherited keys from mime extension map", () => { | |
| 36 | + expect(inferImageExtension({ mimeType: "constructor" })).toBe(".bin"); | |
| 37 | + }); | |
| 27 | 38 | }); |
| @@ -32,7 +32,7 @@ export const SAFE_IMAGE_FILE_EXTENSIONS = new Set([ · 31 unmodified lines | ||
| 32 | 32 | export function parseBase64DataUrl( |
| 33 | 33 | dataUrl: string, |
| 34 | 34 | ): { readonly mimeType: string; readonly base64: string } | null { |
| 35 | − const match = /^data:([^,]+),([a-z0-9+/=\r\n]+)$/i.exec(dataUrl.trim()); | |
| 35 | + const match = /^data:([^,]+),([a-z0-9+/=\r\n ]+)$/i.exec(dataUrl.trim()); | |
| 36 | 36 | if (!match) return null; |
| 37 | 37 | |
| 38 | 38 | const headerParts = (match[1] ?? "") |
| @@ -55,7 +55,10 @@ export function parseBase64DataUrl( · 16 unmodified lines | ||
| 55 | 55 | } |
| 56 | 56 | |
| 57 | 57 | export function inferImageExtension(input: { mimeType: string; fileName?: string }): string { |
| 58 | − const fromMime = IMAGE_EXTENSION_BY_MIME_TYPE[input.mimeType.toLowerCase()]; | |
| 58 | + const key = input.mimeType.toLowerCase(); | |
| 59 | + const fromMime = Object.hasOwn(IMAGE_EXTENSION_BY_MIME_TYPE, key) | |
| 60 | + ? IMAGE_EXTENSION_BY_MIME_TYPE[key] | |
| 61 | + : undefined; | |
| 59 | 62 | if (fromMime) { |
| 60 | 63 | return fromMime; |
| 61 | 64 | } |
| @@ -1,5 +1,3 @@ | ||
| 1 | −import fs from "node:fs"; | |
| 2 | − | |
| 3 | 1 | import { |
| 4 | 2 | type ChatAttachment, |
| 5 | 3 | CommandId, |
| @@ -14,7 +12,7 @@ import { · 8 unmodified lines | ||
| 14 | 12 | type ProviderThreadId, |
| 15 | 13 | type TurnId, |
| 16 | 14 | } from "@t3tools/contracts"; |
| 17 | −import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Stream } from "effect"; | |
| 15 | +import { Cache, Cause, Duration, Effect, FileSystem, Layer, Option, Queue, Stream } from "effect"; | |
| 18 | 16 | |
| 19 | 17 | import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; |
| 20 | 18 | import { |
| @@ -111,6 +109,7 @@ const make = Effect.gen(function* () { · 90 unmodified lines | ||
| 111 | 109 | const providerService = yield* ProviderService; |
| 112 | 110 | const git = yield* GitCore; |
| 113 | 111 | const textGeneration = yield* TextGeneration; |
| 112 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 114 | 113 | const serverConfig = yield* Effect.service(ServerConfig); |
| 115 | 114 | const handledTurnStartKeys = yield* Cache.make<string, true>({ |
| 116 | 115 | capacity: HANDLED_TURN_START_KEY_MAX, |
| @@ -333,14 +332,10 @@ const make = Effect.gen(function* () { · 216 unmodified lines | ||
| 333 | 332 | return attachment; |
| 334 | 333 | } |
| 335 | 334 | |
| 336 | − const isFile = yield* Effect.sync(() => { | |
| 337 | − try { | |
| 338 | − return fs.statSync(resolvedMaterializedPath).isFile(); | |
| 339 | − } catch { | |
| 340 | − return false; | |
| 341 | − } | |
| 342 | − }); | |
| 343 | − if (!isFile) { | |
| 335 | + const fileInfo = yield* fileSystem | |
| 336 | + .stat(resolvedMaterializedPath) | |
| 337 | + .pipe(Effect.catch(() => Effect.succeed(null))); | |
| 338 | + if (!fileInfo || fileInfo.type !== "File") { | |
| 344 | 339 | return attachment; |
| 345 | 340 | } |
| 346 | 341 | return { |