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 | 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 { |