| author | |
| committer | |
| log | 41734dfbbfd47d2d9639dfbdd0912bb623e51403 |
| tree | 5f631d6c7ddf955d4229479666aadc1035699c5a |
| parent | 8f42d34db921fa623715b9524aa3894919dec8bb |
Co-authored-by: codex <codex@users.noreply.github.com>5 files changed, 108 insertions(+), 25 deletions(-)
apps/server/src/git/Layers/CodexTextGeneration.test.ts+57| ... | ... | @@ -349,6 +349,15 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { |
| 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) => { |
| 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 | () => |
apps/server/src/git/Layers/CodexTextGeneration.ts+28-11| ... | ... | @@ -107,6 +107,11 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 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* () { |
| 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* () { |
| 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* () { |
| 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* () { |
| 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* () { |
| 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* () { |
| 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* () { |
| 476 | 492 | branch: Schema.String, |
| 477 | 493 | }), |
| 478 | 494 | imagePaths, |
| 495 | cleanupPaths: tempImagePaths, | |
| 479 | 496 | }); |
| 480 | 497 | |
| 481 | 498 | return { |
apps/server/src/imageMime.test.ts+12-1| ... | ... | @@ -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", () => { |
| 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 | }); |
apps/server/src/imageMime.ts+5-2| ... | ... | @@ -32,7 +32,7 @@ export const SAFE_IMAGE_FILE_EXTENSIONS = new Set([ |
| 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( |
| 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 | } |
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+6-11| ... | ... | @@ -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 { |
| 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* () { |
| 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* () { |
| 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 { |