authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-01 00:57:31-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-01 20:06:21-08:00
log41734dfbbfd47d2d9639dfbdd0912bb623e51403
tree5f631d6c7ddf955d4229479666aadc1035699c5a
parent8f42d34db921fa623715b9524aa3894919dec8bb

fix: address PR review feedback

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 );
362371
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);
109109
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 }
184191
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 }
211219
...@@ -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* fileSystem227 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 };
230244
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 });
314330
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* () {
438454
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 });
480497
481 return {498 return {
apps/server/src/imageMime.test.ts+12-1
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
22
3import { parseBase64DataUrl } from "./imageMime.ts";3import { inferImageExtension, parseBase64DataUrl } from "./imageMime.ts";
44
5describe("imageMime", () => {5describe("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([
32export function parseBase64DataUrl(32export 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;
3737
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}
5656
57export function inferImageExtension(input: { mimeType: string; fileName?: string }): string {57export 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 @@
1import fs from "node:fs";
2
3import {1import {
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";
17import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Stream } from "effect";15import { Cache, Cause, Duration, Effect, FileSystem, Layer, Option, Queue, Stream } from "effect";
1816
19import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";17import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";
20import {18import {
...@@ -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 }
335334
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 {