Showing the changes to apps/server/src/git (2 of 5 files). Show all
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 | .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) => { · 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 | 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 | () => |
| @@ -107,6 +107,11 @@ const makeCodexTextGeneration = Effect.gen(function* () { · 106 unmodified lines | |||
| 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* () { · 63 unmodified lines | |||
| 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* () { · 20 unmodified lines | |||
| 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* () { · 1 unmodified line | |||
| 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* () { · 1 unmodified line | |||
| 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* () { · 67 unmodified lines | |||
| 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* () { · 118 unmodified lines | |||
| 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* () { · 31 unmodified lines | |||
| 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 { |