From 80e4a5d5cc9c669c9846037a1cbc49b0e70ef6bf Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 9 May 2026 16:59:25 +0000 Subject: [PATCH 1/3] Initial commit with task details Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: https://github.com/ProverCoderAI/docker-git/issues/261 --- .gitkeep | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 00000000..0468d88a --- /dev/null +++ b/.gitkeep @@ -0,0 +1 @@ +# .gitkeep file auto-generated at 2026-05-09T16:59:25.384Z for PR creation at branch issue-261-2c9f5e5b48ce for issue https://github.com/ProverCoderAI/docker-git/issues/261 \ No newline at end of file From dfe3f18921ded312a91a91b868cbd9fcce64078f Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 9 May 2026 17:08:26 +0000 Subject: [PATCH 2/3] feat(terminal): preview images shown by codex viewed image output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue 261 reports that the inline terminal image preview added in PR 241 does not trigger for the "Viewed Image" output that the Codex CLI prints as a tree pointer line followed by a relative path: • Viewed Image └ app/docs/screenshots/issue-237/proof/pr238-proof-...png The detector previously only matched absolute paths starting with "/", and the api fetch validator rejected anything that was not absolute. This change adds a second detection pass anchored on the tree pointer glyphs `└` and `├` and a relative path with a supported image extension. Detection deduplicates against the existing absolute-path pass so plain absolute paths preceded by a pointer are not double-counted. The api validator `planTerminalImageFetch` now accepts an optional `baseDir` and resolves bare relative paths against it. Terminal sessions remember the project's target dir (e.g. `/home/dev/app`) and pass it as the base directory when fetching an image, so the relative path printed by codex resolves to the same file the user is looking at. All previous safety checks still apply: the base dir itself must be absolute and free of traversal/control characters, and the joined path is re-validated for traversal segments and unsafe characters before any docker exec runs. Tests cover the detection and validator changes including the issue 261 reproduction, alternative `├` pointer, multiple consecutive viewed images, no-base-dir backwards compatibility, and unsafe-base rejection. Refs ProverCoderAI/docker-git#261 --- .../src/services/terminal-image-fetch-core.ts | 30 ++++++++++-- .../api/src/services/terminal-sessions.ts | 13 +++-- .../tests/terminal-image-fetch-core.test.ts | 49 +++++++++++++++++++ packages/app/src/web/terminal-image-paths.ts | 49 ++++++++++++++----- .../docker-git/terminal-image-paths.test.ts | 33 +++++++++++++ 5 files changed, 153 insertions(+), 21 deletions(-) diff --git a/packages/api/src/services/terminal-image-fetch-core.ts b/packages/api/src/services/terminal-image-fetch-core.ts index 5eb53c6c..b68d17ec 100644 --- a/packages/api/src/services/terminal-image-fetch-core.ts +++ b/packages/api/src/services/terminal-image-fetch-core.ts @@ -103,7 +103,21 @@ const normalizeTerminalImagePath = (path: string): TerminalImagePathNormalizatio } } -export const planTerminalImageFetch = (path: string): TerminalImageFetchPlan => { +export type TerminalImageFetchOptions = { + readonly baseDir?: string +} + +const isAbsolutePosixPath = (value: string): boolean => value.startsWith("/") + +const joinBaseDirAndRelativePath = (baseDir: string, relativePath: string): string => { + const trimmedBase = baseDir.replace(/\/+$/u, "") + return `${trimmedBase}/${relativePath}` +} + +export const planTerminalImageFetch = ( + path: string, + options: TerminalImageFetchOptions = {} +): TerminalImageFetchPlan => { if (typeof path !== "string" || path.length === 0) { return { _tag: "InvalidTerminalImageFetch", message: "Image path is required." } } @@ -111,9 +125,17 @@ export const planTerminalImageFetch = (path: string): TerminalImageFetchPlan => if (normalized._tag === "InvalidTerminalImagePath") { return { _tag: "InvalidTerminalImageFetch", message: normalized.message } } - const containerPath = normalized.path - if (!containerPath.startsWith("/")) { - return { _tag: "InvalidTerminalImageFetch", message: "Image path must be absolute." } + const normalizedPath = normalized.path + let containerPath = normalizedPath + if (!isAbsolutePosixPath(containerPath)) { + const baseDir = options.baseDir + if (baseDir === undefined || !isAbsolutePosixPath(baseDir)) { + return { _tag: "InvalidTerminalImageFetch", message: "Image path must be absolute." } + } + if (invalidCharacterPattern.test(baseDir) || traversalPattern.test(baseDir)) { + return { _tag: "InvalidTerminalImageFetch", message: "Image base directory is invalid." } + } + containerPath = joinBaseDirAndRelativePath(baseDir, containerPath) } if (invalidCharacterPattern.test(containerPath)) { return { _tag: "InvalidTerminalImageFetch", message: "Image path contains invalid characters." } diff --git a/packages/api/src/services/terminal-sessions.ts b/packages/api/src/services/terminal-sessions.ts index 021bf14d..de8f9472 100644 --- a/packages/api/src/services/terminal-sessions.ts +++ b/packages/api/src/services/terminal-sessions.ts @@ -59,6 +59,7 @@ type TerminalRecord = { projectDisplayName: string projectId: string projectKey: string + projectTargetDir: string prepared: ReturnType } @@ -581,7 +582,8 @@ const registerRecord = ( projectKey: string, projectDisplayName: string, prepared: ReturnType, - projectContainerName: string + projectContainerName: string, + projectTargetDir: string ): TerminalSession => { const session: TerminalSession = { attachedClients: 0, @@ -600,6 +602,7 @@ const registerRecord = ( projectDisplayName, projectId, projectKey, + projectTargetDir, pty: null, session, sockets: new Set() @@ -662,7 +665,8 @@ export const createTerminalSession = ( project.projectKey, project.displayName, prepared, - projectItem.containerName + projectItem.containerName, + projectItem.targetDir ) yield* _(emitTerminalSessionCreated(projectId, session.id, options.requestId)) return { project, session } @@ -681,7 +685,8 @@ export const createTerminalSession = ( project.projectKey, project.displayName, prepared, - reachableProjectItem.containerName + reachableProjectItem.containerName, + reachableProjectItem.targetDir ) yield* _(emitTerminalSessionCreated(projectId, session.id, options.requestId)) yield* _(emitTerminalStatus(projectId, "ssh.post-start", "Post-start self-heal continues in background")) @@ -786,7 +791,7 @@ export const readProjectTerminalImage = ( Effect.fail(new ApiNotFoundError({ message: `Terminal session not found: ${sessionId}` })) ) } - const plan = planTerminalImageFetch(imagePath) + const plan = planTerminalImageFetch(imagePath, { baseDir: record.projectTargetDir }) if (plan._tag === "InvalidTerminalImageFetch") { return yield* _(Effect.fail(new ApiBadRequestError({ message: plan.message }))) } diff --git a/packages/api/tests/terminal-image-fetch-core.test.ts b/packages/api/tests/terminal-image-fetch-core.test.ts index a9f38b39..eebe5764 100644 --- a/packages/api/tests/terminal-image-fetch-core.test.ts +++ b/packages/api/tests/terminal-image-fetch-core.test.ts @@ -103,4 +103,53 @@ describe("terminal image fetch core", () => { _tag: "InvalidTerminalImageFetch" }) }) + + it("resolves a relative path against the provided absolute base directory", () => { + expect( + planTerminalImageFetch( + "app/docs/screenshots/issue-237/proof/pr238-proof-06-skiller-submodule-status.png", + { baseDir: "/home/dev" } + ) + ).toEqual({ + _tag: "ValidTerminalImageFetch", + containerPath: "/home/dev/app/docs/screenshots/issue-237/proof/pr238-proof-06-skiller-submodule-status.png", + mediaType: "image/png" + }) + }) + + it("ignores trailing slashes on the base directory when resolving a relative path", () => { + expect(planTerminalImageFetch("photos/cover.jpg", { baseDir: "/home/dev/" })).toEqual({ + _tag: "ValidTerminalImageFetch", + containerPath: "/home/dev/photos/cover.jpg", + mediaType: "image/jpeg" + }) + }) + + it("still rejects relative paths when no base directory is supplied", () => { + expect(planTerminalImageFetch("app/cover.png")).toEqual({ + _tag: "InvalidTerminalImageFetch", + message: "Image path must be absolute." + }) + }) + + it("rejects relative paths when the supplied base directory is not absolute", () => { + expect(planTerminalImageFetch("app/cover.png", { baseDir: "home/dev" })).toEqual({ + _tag: "InvalidTerminalImageFetch", + message: "Image path must be absolute." + }) + }) + + it("rejects an unsafe base directory containing traversal segments", () => { + expect(planTerminalImageFetch("cover.png", { baseDir: "/home/dev/../etc" })).toEqual({ + _tag: "InvalidTerminalImageFetch", + message: "Image base directory is invalid." + }) + }) + + it("rejects relative paths that contain traversal segments after resolution", () => { + expect(planTerminalImageFetch("../etc/cover.png", { baseDir: "/home/dev" })).toMatchObject({ + _tag: "InvalidTerminalImageFetch", + message: "Image path must not contain '.' or '..' segments." + }) + }) }) diff --git a/packages/app/src/web/terminal-image-paths.ts b/packages/app/src/web/terminal-image-paths.ts index 903ddf23..dc8be54c 100644 --- a/packages/app/src/web/terminal-image-paths.ts +++ b/packages/app/src/web/terminal-image-paths.ts @@ -8,6 +8,13 @@ const imagePathPattern = new RegExp( "giu" ) +const treePointerImagePathSource = + String.raw`[^\s"'(<>\[\]{}|\\/][^\s"'(<>\[\]{}|\\]*\.(?:${extensionAlternation})` +const treePointerImagePathPattern = new RegExp( + String.raw`(?:^|\s)[└├]\s+(${treePointerImagePathSource})(?=$|[\s"')<>\[\]{}|.,;:?!])`, + "giu" +) + const escapeChar = String.fromCodePoint(0x1B) const bellChar = String.fromCodePoint(0x07) @@ -26,22 +33,38 @@ export type TerminalImagePathMatch = { export const stripTerminalAnsi = (text: string): string => text.replace(ansiOscPattern, "").replace(ansiCsiPattern, "").replace(ansiOtherEscapePattern, "") -export const detectTerminalImagePathMatches = (text: string): ReadonlyArray => { - const plainText = stripTerminalAnsi(text) - const matches: Array = [] - for (const match of plainText.matchAll(imagePathPattern)) { +const collectPatternMatches = ( + plainText: string, + pattern: RegExp, + matches: Array, + seenStartIndices: Set +): void => { + for (const match of plainText.matchAll(pattern)) { const candidate = match[1] - if (candidate !== undefined && candidate.length > 0) { - const fullMatch = match[0] - const fullStartIndex = match.index - const startIndex = fullStartIndex + fullMatch.lastIndexOf(candidate) - matches.push({ - endIndex: startIndex + candidate.length, - path: candidate, - startIndex - }) + if (candidate === undefined || candidate.length === 0) { + continue } + const fullMatch = match[0] + const fullStartIndex = match.index + const startIndex = fullStartIndex + fullMatch.lastIndexOf(candidate) + if (seenStartIndices.has(startIndex)) { + continue + } + seenStartIndices.add(startIndex) + matches.push({ + endIndex: startIndex + candidate.length, + path: candidate, + startIndex + }) } +} + +export const detectTerminalImagePathMatches = (text: string): ReadonlyArray => { + const plainText = stripTerminalAnsi(text) + const matches: Array = [] + const seenStartIndices = new Set() + collectPatternMatches(plainText, imagePathPattern, matches, seenStartIndices) + collectPatternMatches(plainText, treePointerImagePathPattern, matches, seenStartIndices) return matches } diff --git a/packages/app/tests/docker-git/terminal-image-paths.test.ts b/packages/app/tests/docker-git/terminal-image-paths.test.ts index ec30dc3b..f999ac27 100644 --- a/packages/app/tests/docker-git/terminal-image-paths.test.ts +++ b/packages/app/tests/docker-git/terminal-image-paths.test.ts @@ -98,4 +98,37 @@ describe("terminal image path detection", () => { expect(isSupportedTerminalImagePath("/var/data/a.bmp")).toBe(false) expect(isSupportedTerminalImagePath("/var/data/a.txt")).toBe(false) }) + + it("detects relative image paths under a tree pointer (issue 261 viewed image format)", () => { + const text = " └ app/docs/screenshots/issue-237/proof/pr238-proof-06-skiller-submodule-status.png" + expect(detectTerminalImagePaths(text)).toEqual([ + "app/docs/screenshots/issue-237/proof/pr238-proof-06-skiller-submodule-status.png" + ]) + }) + + it("detects relative image paths under the alternative branch tree pointer", () => { + expect(detectTerminalImagePaths(" ├ assets/cover.jpg")).toEqual(["assets/cover.jpg"]) + }) + + it("detects multiple viewed images across separate lines", () => { + const text = [ + "• Viewed Image", + " └ app/docs/screenshots/issue-237/proof/pr238-proof-06-skiller-submodule-status.png", + "", + "• Viewed Image", + " └ app/docs/screenshots/issue-237/proof/pr238-proof-09-skiller-submodule-checks.png" + ].join("\n") + expect(detectTerminalImagePaths(text)).toEqual([ + "app/docs/screenshots/issue-237/proof/pr238-proof-06-skiller-submodule-status.png", + "app/docs/screenshots/issue-237/proof/pr238-proof-09-skiller-submodule-checks.png" + ]) + }) + + it("does not duplicate absolute paths preceded by a tree pointer", () => { + expect(detectTerminalImagePaths(" └ /var/data/foo.png")).toEqual(["/var/data/foo.png"]) + }) + + it("ignores tree pointers that do not precede an image", () => { + expect(detectTerminalImagePaths(" └ Viewed File: notes.txt")).toEqual([]) + }) }) From 2798250a03d9d2b0e1dbfe93db8d4f42fbb4e4ab Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 9 May 2026 17:15:49 +0000 Subject: [PATCH 3/3] Revert "Initial commit with task details" This reverts commit 80e4a5d5cc9c669c9846037a1cbc49b0e70ef6bf. --- .gitkeep | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep deleted file mode 100644 index 0468d88a..00000000 --- a/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# .gitkeep file auto-generated at 2026-05-09T16:59:25.384Z for PR creation at branch issue-261-2c9f5e5b48ce for issue https://github.com/ProverCoderAI/docker-git/issues/261 \ No newline at end of file