| 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,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 | .pipe( | 361 | .pipe( |
| 353 | Effect.ensuring( | 362 | Effect.ensuring( |
| 354 | fs.remove(imagePath).pipe(Effect.catch(() => Effect.void)), | 363 | fs.remove(imagePath).pipe(Effect.catch(() => Effect.void)), |
| ... | @@ -360,6 +369,54 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { | ... | @@ -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 | it.effect( | 420 | it.effect( |
| 364 | "fails with typed TextGenerationError when codex returns wrong branch payload shape", | 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,6 +107,11 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 107 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 107 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 108 | const serverConfig = yield* Effect.service(ServerConfig); | 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 | const readStreamAsString = <E>( | 115 | const readStreamAsString = <E>( |
| 111 | operation: string, | 116 | operation: string, |
| 112 | stream: Stream.Stream<Uint8Array, E>, | 117 | stream: Stream.Stream<Uint8Array, E>, |
| ... | @@ -176,10 +181,12 @@ const makeCodexTextGeneration = Effect.gen(function* () { | ... | @@ -176,10 +181,12 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 176 | const materializeImageAttachments = ( | 181 | const materializeImageAttachments = ( |
| 177 | operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName", | 182 | operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName", |
| 178 | attachments: BranchNameGenerationInput["attachments"], | 183 | attachments: BranchNameGenerationInput["attachments"], |
| 179 | ): Effect.Effect<ReadonlyArray<string>, TextGenerationError> => | 184 | ): Effect.Effect<MaterializedImageAttachments, TextGenerationError> => { |
| 180 | Effect.gen(function* () { | 185 | const tempImagePaths: string[] = []; |
| 186 | |||
| 187 | return Effect.gen(function* () { | ||
| 181 | if (!attachments || attachments.length === 0) { | 188 | if (!attachments || attachments.length === 0) { |
| 182 | return []; | 189 | return { imagePaths: [], tempImagePaths }; |
| 183 | } | 190 | } |
| 184 | 191 | ||
| 185 | const imagePaths: string[] = []; | 192 | const imagePaths: string[] = []; |
| ... | @@ -206,6 +213,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { | ... | @@ -206,6 +213,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 206 | extension, | 213 | extension, |
| 207 | ); | 214 | ); |
| 208 | imagePaths.push(imagePath); | 215 | imagePaths.push(imagePath); |
| 216 | tempImagePaths.push(imagePath); | ||
| 209 | continue; | 217 | continue; |
| 210 | } | 218 | } |
| 211 | 219 | ||
| ... | @@ -213,20 +221,26 @@ const makeCodexTextGeneration = Effect.gen(function* () { | ... | @@ -213,20 +221,26 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 213 | stateDir: serverConfig.stateDir, | 221 | stateDir: serverConfig.stateDir, |
| 214 | dataUrl: attachment.dataUrl, | 222 | dataUrl: attachment.dataUrl, |
| 215 | }); | 223 | }); |
| 216 | const localPath = persistedPath ?? attachment.dataUrl; | 224 | if (!persistedPath || !path.isAbsolute(persistedPath)) { |
| 217 | if (!path.isAbsolute(localPath)) { | ||
| 218 | continue; | 225 | continue; |
| 219 | } | 226 | } |
| 220 | const fileInfo = yield* fileSystem | 227 | const fileInfo = yield* fileSystem |
| 221 | .stat(localPath) | 228 | .stat(persistedPath) |
| 222 | .pipe(Effect.catch(() => Effect.succeed(null))); | 229 | .pipe(Effect.catch(() => Effect.succeed(null))); |
| 223 | if (!fileInfo || fileInfo.type !== "File") { | 230 | if (!fileInfo || fileInfo.type !== "File") { |
| 224 | continue; | 231 | continue; |
| 225 | } | 232 | } |
| 226 | imagePaths.push(localPath); | 233 | imagePaths.push(persistedPath); |
| 227 | } | 234 | } |
| 228 | return imagePaths; | 235 | return { imagePaths, tempImagePaths }; |
| 229 | }); | 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 | const runCodexJson = <S extends Schema.Top>({ | 245 | const runCodexJson = <S extends Schema.Top>({ |
| 232 | operation, | 246 | operation, |
| ... | @@ -234,12 +248,14 @@ const makeCodexTextGeneration = Effect.gen(function* () { | ... | @@ -234,12 +248,14 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 234 | prompt, | 248 | prompt, |
| 235 | outputSchemaJson, | 249 | outputSchemaJson, |
| 236 | imagePaths = [], | 250 | imagePaths = [], |
| 251 | cleanupPaths = [], | ||
| 237 | }: { | 252 | }: { |
| 238 | operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName"; | 253 | operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName"; |
| 239 | cwd: string; | 254 | cwd: string; |
| 240 | prompt: string; | 255 | prompt: string; |
| 241 | outputSchemaJson: S; | 256 | outputSchemaJson: S; |
| 242 | imagePaths?: ReadonlyArray<string>; | 257 | imagePaths?: ReadonlyArray<string>; |
| 258 | cleanupPaths?: ReadonlyArray<string>; | ||
| 243 | }): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> => | 259 | }): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> => |
| 244 | Effect.gen(function* () { | 260 | Effect.gen(function* () { |
| 245 | const schemaPath = yield* writeTempFile( | 261 | const schemaPath = yield* writeTempFile( |
| ... | @@ -313,7 +329,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { | ... | @@ -313,7 +329,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 313 | }); | 329 | }); |
| 314 | 330 | ||
| 315 | const cleanup = Effect.all( | 331 | const cleanup = Effect.all( |
| 316 | [schemaPath, outputPath, ...imagePaths].map((filePath) => safeUnlink(filePath)), | 332 | [schemaPath, outputPath, ...cleanupPaths].map((filePath) => safeUnlink(filePath)), |
| 317 | { | 333 | { |
| 318 | concurrency: "unbounded", | 334 | concurrency: "unbounded", |
| 319 | }, | 335 | }, |
| ... | @@ -438,7 +454,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { | ... | @@ -438,7 +454,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 438 | 454 | ||
| 439 | const generateBranchName: TextGenerationShape["generateBranchName"] = (input) => { | 455 | const generateBranchName: TextGenerationShape["generateBranchName"] = (input) => { |
| 440 | return Effect.gen(function* () { | 456 | return Effect.gen(function* () { |
| 441 | const imagePaths = yield* materializeImageAttachments( | 457 | const { imagePaths, tempImagePaths } = yield* materializeImageAttachments( |
| 442 | "generateBranchName", | 458 | "generateBranchName", |
| 443 | input.attachments, | 459 | input.attachments, |
| 444 | ); | 460 | ); |
| ... | @@ -476,6 +492,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { | ... | @@ -476,6 +492,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 476 | branch: Schema.String, | 492 | branch: Schema.String, |
| 477 | }), | 493 | }), |
| 478 | imagePaths, | 494 | imagePaths, |
| 495 | cleanupPaths: tempImagePaths, | ||
| 479 | }); | 496 | }); |
| 480 | 497 | ||
| 481 | return { | 498 | return { |
apps/server/src/imageMime.test.ts+12-1| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import { describe, expect, it } from "vitest"; | 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 | describe("imageMime", () => { | 5 | describe("imageMime", () => { |
| 6 | it("parses base64 data URL with mime type", () => { | 6 | it("parses base64 data URL with mime type", () => { |
| ... | @@ -24,4 +24,15 @@ describe("imageMime", () => { | ... | @@ -24,4 +24,15 @@ describe("imageMime", () => { |
| 24 | it("rejects missing mime type", () => { | 24 | it("rejects missing mime type", () => { |
| 25 | expect(parseBase64DataUrl("data:;base64,SGVsbG8=")).toBeNull(); | 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,7 +32,7 @@ export const SAFE_IMAGE_FILE_EXTENSIONS = new Set([ |
| 32 | export function parseBase64DataUrl( | 32 | export function parseBase64DataUrl( |
| 33 | dataUrl: string, | 33 | dataUrl: string, |
| 34 | ): { readonly mimeType: string; readonly base64: string } | null { | 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 | if (!match) return null; | 36 | if (!match) return null; |
| 37 | 37 | ||
| 38 | const headerParts = (match[1] ?? "") | 38 | const headerParts = (match[1] ?? "") |
| ... | @@ -55,7 +55,10 @@ export function parseBase64DataUrl( | ... | @@ -55,7 +55,10 @@ export function parseBase64DataUrl( |
| 55 | } | 55 | } |
| 56 | 56 | ||
| 57 | export function inferImageExtension(input: { mimeType: string; fileName?: string }): string { | 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 | if (fromMime) { | 62 | if (fromMime) { |
| 60 | return fromMime; | 63 | return fromMime; |
| 61 | } | 64 | } |
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+6-11| ... | @@ -1,5 +1,3 @@ | ... | @@ -1,5 +1,3 @@ |
| 1 | import fs from "node:fs"; | ||
| 2 | |||
| 3 | import { | 1 | import { |
| 4 | type ChatAttachment, | 2 | type ChatAttachment, |
| 5 | CommandId, | 3 | CommandId, |
| ... | @@ -14,7 +12,7 @@ import { | ... | @@ -14,7 +12,7 @@ import { |
| 14 | type ProviderThreadId, | 12 | type ProviderThreadId, |
| 15 | type TurnId, | 13 | type TurnId, |
| 16 | } from "@t3tools/contracts"; | 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 | import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; | 17 | import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; |
| 20 | import { | 18 | import { |
| ... | @@ -111,6 +109,7 @@ const make = Effect.gen(function* () { | ... | @@ -111,6 +109,7 @@ const make = Effect.gen(function* () { |
| 111 | const providerService = yield* ProviderService; | 109 | const providerService = yield* ProviderService; |
| 112 | const git = yield* GitCore; | 110 | const git = yield* GitCore; |
| 113 | const textGeneration = yield* TextGeneration; | 111 | const textGeneration = yield* TextGeneration; |
| 112 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 114 | const serverConfig = yield* Effect.service(ServerConfig); | 113 | const serverConfig = yield* Effect.service(ServerConfig); |
| 115 | const handledTurnStartKeys = yield* Cache.make<string, true>({ | 114 | const handledTurnStartKeys = yield* Cache.make<string, true>({ |
| 116 | capacity: HANDLED_TURN_START_KEY_MAX, | 115 | capacity: HANDLED_TURN_START_KEY_MAX, |
| ... | @@ -333,14 +332,10 @@ const make = Effect.gen(function* () { | ... | @@ -333,14 +332,10 @@ const make = Effect.gen(function* () { |
| 333 | return attachment; | 332 | return attachment; |
| 334 | } | 333 | } |
| 335 | 334 | ||
| 336 | const isFile = yield* Effect.sync(() => { | 335 | const fileInfo = yield* fileSystem |
| 337 | try { | 336 | .stat(resolvedMaterializedPath) |
| 338 | return fs.statSync(resolvedMaterializedPath).isFile(); | 337 | .pipe(Effect.catch(() => Effect.succeed(null))); |
| 339 | } catch { | 338 | if (!fileInfo || fileInfo.type !== "File") { |
| 340 | return false; | ||
| 341 | } | ||
| 342 | }); | ||
| 343 | if (!isFile) { | ||
| 344 | return attachment; | 339 | return attachment; |
| 345 | } | 340 | } |
| 346 | return { | 341 | return { |