Skip to content

Commit e43449d

Browse files
authored
Merge pull request #241 from konard/issue-240-b08428ed1942
feat(app,api): inline terminal image gallery for detected paths
2 parents dcfc526 + c2f11c6 commit e43449d

18 files changed

Lines changed: 847 additions & 23 deletions
45 KB
Loading
16.2 KB
Loading

packages/api/src/http.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,8 @@ import {
113113
deleteTerminalSession,
114114
getProjectTerminalSession,
115115
listProjectTerminalSessions,
116-
lookupTerminalSessionById
116+
lookupTerminalSessionById,
117+
readProjectTerminalImage
117118
} from "./services/terminal-sessions.js"
118119
import {
119120
commitStateFromRequest,
@@ -223,6 +224,14 @@ const textResponse = (data: string, contentType: string, status = 200) =>
223224
)
224225
)
225226

227+
const binaryResponse = (data: Uint8Array, contentType: string, status = 200) =>
228+
Effect.succeed(
229+
HttpServerResponse.setStatus(
230+
HttpServerResponse.uint8Array(data, { contentType, headers: noStoreHeaders }),
231+
status
232+
)
233+
)
234+
226235
const activityJsonResponse = (data: unknown, status: number) =>
227236
textResponse(JSON.stringify(data), "application/activity+json; charset=utf-8", status)
228237

@@ -1134,7 +1143,31 @@ export const makeRouter = () => {
11341143
)
11351144
)
11361145

1137-
const withAgents = withProjectLifecycle.pipe(
1146+
const withProjectTerminalImages = withProjectLifecycle.pipe(
1147+
HttpRouter.get(
1148+
"/projects/by-key/:projectKey/terminal-sessions/:sessionId/image",
1149+
Effect.gen(function*(_) {
1150+
const { projectKey, sessionId } = yield* _(terminalSessionByProjectKeyParams)
1151+
const request = yield* _(HttpServerRequest.HttpServerRequest)
1152+
const imagePath = new URL(request.url, "http://localhost").searchParams.get("path") ?? ""
1153+
const project = yield* _(getProjectItemByKey(projectKey))
1154+
const result = yield* _(readProjectTerminalImage(project.projectDir, sessionId, imagePath))
1155+
return yield* _(binaryResponse(result.bytes, result.mediaType, 200))
1156+
}).pipe(Effect.catchAll(errorResponse))
1157+
),
1158+
HttpRouter.get(
1159+
"/projects/:projectId/terminal-sessions/:sessionId/image",
1160+
Effect.gen(function*(_) {
1161+
const { projectId, sessionId } = yield* _(terminalSessionParams)
1162+
const request = yield* _(HttpServerRequest.HttpServerRequest)
1163+
const imagePath = new URL(request.url, "http://localhost").searchParams.get("path") ?? ""
1164+
const result = yield* _(readProjectTerminalImage(projectId, sessionId, imagePath))
1165+
return yield* _(binaryResponse(result.bytes, result.mediaType, 200))
1166+
}).pipe(Effect.catchAll(errorResponse))
1167+
)
1168+
)
1169+
1170+
const withAgents = withProjectTerminalImages.pipe(
11381171
HttpRouter.post(
11391172
"/projects/:projectId/agents",
11401173
Effect.gen(function*(_) {
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
export type TerminalImageFetchPlan =
2+
| {
3+
readonly _tag: "InvalidTerminalImageFetch"
4+
readonly message: string
5+
}
6+
| {
7+
readonly _tag: "ValidTerminalImageFetch"
8+
readonly containerPath: string
9+
readonly mediaType: string
10+
}
11+
12+
export const terminalImageFetchMaxBytes = 10 * 1024 * 1024
13+
14+
const supportedExtensionMediaTypes = new Map<string, string>([
15+
["gif", "image/gif"],
16+
["jpeg", "image/jpeg"],
17+
["jpg", "image/jpeg"],
18+
["png", "image/png"],
19+
["webp", "image/webp"]
20+
])
21+
22+
const controlCharRange = `${String.fromCodePoint(0)}-${String.fromCodePoint(0x1F)}`
23+
const deleteChar = String.fromCodePoint(0x7F)
24+
const invalidCharacterPattern = new RegExp(String.raw`[\s${controlCharRange}${deleteChar}]`, "u")
25+
const traversalPattern = /(?:^|\/)(?:\.|\.\.)(?=\/|$)/u
26+
27+
const lowercaseExtension = (path: string): string | null => {
28+
const lastDot = path.lastIndexOf(".")
29+
if (lastDot < 0 || lastDot === path.length - 1) {
30+
return null
31+
}
32+
return path.slice(lastDot + 1).toLowerCase()
33+
}
34+
35+
export const planTerminalImageFetch = (path: string): TerminalImageFetchPlan => {
36+
if (typeof path !== "string" || path.length === 0) {
37+
return { _tag: "InvalidTerminalImageFetch", message: "Image path is required." }
38+
}
39+
if (!path.startsWith("/")) {
40+
return { _tag: "InvalidTerminalImageFetch", message: "Image path must be absolute." }
41+
}
42+
if (invalidCharacterPattern.test(path)) {
43+
return { _tag: "InvalidTerminalImageFetch", message: "Image path contains invalid characters." }
44+
}
45+
if (traversalPattern.test(path)) {
46+
return { _tag: "InvalidTerminalImageFetch", message: "Image path must not contain '.' or '..' segments." }
47+
}
48+
const extension = lowercaseExtension(path)
49+
if (extension === null) {
50+
return { _tag: "InvalidTerminalImageFetch", message: "Image path must include a file extension." }
51+
}
52+
const mediaType = supportedExtensionMediaTypes.get(extension)
53+
if (mediaType === undefined) {
54+
return { _tag: "InvalidTerminalImageFetch", message: `Unsupported image extension: .${extension}` }
55+
}
56+
return { _tag: "ValidTerminalImageFetch", containerPath: path, mediaType }
57+
}

packages/api/src/services/terminal-sessions.ts

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,12 @@ import type { Duplex } from "node:stream"
1515
import { WebSocket, WebSocketServer, type RawData } from "ws"
1616

1717
import type { TerminalSession, TerminalSessionStatus } from "../api/contracts.js"
18-
import { ApiInternalError, ApiNotFoundError, describeUnknown } from "../api/errors.js"
18+
import { ApiBadRequestError, ApiInternalError, ApiNotFoundError, describeUnknown } from "../api/errors.js"
1919
import { emitProjectEvent } from "./events.js"
20+
import {
21+
planTerminalImageFetch,
22+
terminalImageFetchMaxBytes
23+
} from "./terminal-image-fetch-core.js"
2024
import {
2125
createTerminalImagePastePlan,
2226
terminalImagePasteDirectory,
@@ -392,6 +396,91 @@ const writeBufferToProjectContainer = (
392396
child.stdin.end(buffer)
393397
})
394398

399+
const readBufferFromProjectContainer = (
400+
containerName: string,
401+
containerPath: string,
402+
maxBytes: number
403+
): Effect.Effect<Buffer, ApiInternalError | ApiBadRequestError | ApiNotFoundError> =>
404+
Effect.async((resume) => {
405+
const child = spawn(
406+
"docker",
407+
[
408+
"exec",
409+
"-u",
410+
"dev",
411+
containerName,
412+
"cat",
413+
"--",
414+
containerPath
415+
],
416+
{
417+
cwd: process.cwd(),
418+
stdio: ["ignore", "pipe", "pipe"]
419+
}
420+
)
421+
const stdoutChunks: Array<Buffer> = []
422+
const stderrChunks: Array<Buffer> = []
423+
let totalBytes = 0
424+
let exceededLimit = false
425+
let completed = false
426+
const resumeOnce = (
427+
effect: Effect.Effect<Buffer, ApiInternalError | ApiBadRequestError | ApiNotFoundError>
428+
): void => {
429+
if (completed) {
430+
return
431+
}
432+
completed = true
433+
resume(effect)
434+
}
435+
child.stdout.on("data", (chunk: Buffer | string) => {
436+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
437+
totalBytes += buffer.length
438+
if (totalBytes > maxBytes) {
439+
exceededLimit = true
440+
try {
441+
child.kill()
442+
} catch {
443+
// ignore — close handler will resume
444+
}
445+
return
446+
}
447+
stdoutChunks.push(buffer)
448+
})
449+
child.stderr.on("data", (chunk: Buffer | string) => {
450+
stderrChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
451+
})
452+
child.on("error", (error) => {
453+
resumeOnce(Effect.fail(new ApiInternalError({
454+
message: `Failed to run docker exec for ${containerName}.`,
455+
cause: error
456+
})))
457+
})
458+
child.on("close", (exitCode) => {
459+
if (exceededLimit) {
460+
resumeOnce(Effect.fail(new ApiBadRequestError({
461+
message: `Image exceeds maximum size of ${maxBytes} bytes.`
462+
})))
463+
return
464+
}
465+
if (exitCode === 0) {
466+
resumeOnce(Effect.succeed(Buffer.concat(stdoutChunks)))
467+
return
468+
}
469+
const stderr = Buffer.concat(stderrChunks).toString("utf8").trim()
470+
if (/no such file|not a directory|not found/iu.test(stderr)) {
471+
resumeOnce(Effect.fail(new ApiNotFoundError({
472+
message: `Image not found at ${containerPath}.`
473+
})))
474+
return
475+
}
476+
resumeOnce(Effect.fail(new ApiInternalError({
477+
message: stderr.length > 0
478+
? `Failed to read image: ${stderr}`
479+
: `Failed to read image; docker exec exited with code ${exitCode ?? "unknown"}.`
480+
})))
481+
})
482+
})
483+
395484
const saveTerminalImagePaste = (
396485
record: TerminalRecord,
397486
payload: TerminalImagePastePayload
@@ -611,6 +700,33 @@ export const getProjectTerminalSession = (
611700
return record.session
612701
})
613702

703+
export const readProjectTerminalImage = (
704+
projectId: string,
705+
sessionId: string,
706+
imagePath: string
707+
): Effect.Effect<
708+
{ readonly bytes: Buffer; readonly mediaType: string },
709+
ApiBadRequestError | ApiInternalError | ApiNotFoundError
710+
> =>
711+
Effect.gen(function*(_) {
712+
const record = records.get(sessionId)
713+
if (record === undefined || record.projectId !== projectId) {
714+
return yield* _(
715+
Effect.fail(new ApiNotFoundError({ message: `Terminal session not found: ${sessionId}` }))
716+
)
717+
}
718+
const plan = planTerminalImageFetch(imagePath)
719+
if (plan._tag === "InvalidTerminalImageFetch") {
720+
return yield* _(Effect.fail(new ApiBadRequestError({ message: plan.message })))
721+
}
722+
const bytes = yield* _(readBufferFromProjectContainer(
723+
record.projectContainerName,
724+
plan.containerPath,
725+
terminalImageFetchMaxBytes
726+
))
727+
return { bytes, mediaType: plan.mediaType }
728+
})
729+
614730
export const lookupTerminalSessionById = (
615731
sessionId: string
616732
): Effect.Effect<
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { describe, expect, it } from "@effect/vitest"
2+
3+
import { planTerminalImageFetch } from "../src/services/terminal-image-fetch-core.js"
4+
5+
describe("terminal image fetch core", () => {
6+
it("accepts an absolute path with a supported image extension", () => {
7+
expect(planTerminalImageFetch("/tmp/issue232-main.png")).toEqual({
8+
_tag: "ValidTerminalImageFetch",
9+
containerPath: "/tmp/issue232-main.png",
10+
mediaType: "image/png"
11+
})
12+
})
13+
14+
it("maps each supported extension to its media type", () => {
15+
expect(planTerminalImageFetch("/a.jpg")).toMatchObject({ mediaType: "image/jpeg" })
16+
expect(planTerminalImageFetch("/a.jpeg")).toMatchObject({ mediaType: "image/jpeg" })
17+
expect(planTerminalImageFetch("/a.gif")).toMatchObject({ mediaType: "image/gif" })
18+
expect(planTerminalImageFetch("/a.webp")).toMatchObject({ mediaType: "image/webp" })
19+
expect(planTerminalImageFetch("/a.PNG")).toMatchObject({ mediaType: "image/png" })
20+
})
21+
22+
it("rejects an empty path", () => {
23+
expect(planTerminalImageFetch("")).toEqual({
24+
_tag: "InvalidTerminalImageFetch",
25+
message: "Image path is required."
26+
})
27+
})
28+
29+
it("rejects a relative path", () => {
30+
expect(planTerminalImageFetch("tmp/photo.png")).toEqual({
31+
_tag: "InvalidTerminalImageFetch",
32+
message: "Image path must be absolute."
33+
})
34+
})
35+
36+
it("rejects whitespace and control characters", () => {
37+
expect(planTerminalImageFetch("/tmp/has space.png")).toMatchObject({
38+
_tag: "InvalidTerminalImageFetch"
39+
})
40+
expect(planTerminalImageFetch("/tmp/has\nnewline.png")).toMatchObject({
41+
_tag: "InvalidTerminalImageFetch"
42+
})
43+
})
44+
45+
it("rejects parent-directory and current-directory traversal segments", () => {
46+
expect(planTerminalImageFetch("/tmp/../etc/photo.png")).toMatchObject({
47+
_tag: "InvalidTerminalImageFetch"
48+
})
49+
expect(planTerminalImageFetch("/tmp/./photo.png")).toMatchObject({
50+
_tag: "InvalidTerminalImageFetch"
51+
})
52+
})
53+
54+
it("rejects unsupported extensions", () => {
55+
expect(planTerminalImageFetch("/tmp/file.bmp")).toMatchObject({
56+
_tag: "InvalidTerminalImageFetch"
57+
})
58+
expect(planTerminalImageFetch("/tmp/file")).toMatchObject({
59+
_tag: "InvalidTerminalImageFetch"
60+
})
61+
expect(planTerminalImageFetch("/tmp/file.")).toMatchObject({
62+
_tag: "InvalidTerminalImageFetch"
63+
})
64+
})
65+
})

packages/app/src/web/panel-terminal.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -638,9 +638,7 @@ export const TerminalPanel = (
638638
session={session}
639639
status={status}
640640
/>
641-
<div
642-
style={terminalBodyFrameStyle(compactTypingMode, mobileMode)}
643-
>
641+
<div style={terminalBodyFrameStyle(compactTypingMode, mobileMode)}>
644642
<div ref={hostRef} style={terminalHostStyle} />
645643
{hasBodyContent ? <div style={terminalBodyContentStyle}>{bodyContent}</div> : null}
646644
</div>
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
const supportedExtensions: ReadonlyArray<string> = ["png", "jpg", "jpeg", "gif", "webp"]
2+
3+
const extensionAlternation = supportedExtensions.join("|")
4+
5+
const imagePathPattern = new RegExp(
6+
String.raw`(?:^|[\s"'(<>\[\]{}|])(/[^\s"'(<>\[\]{}|\\]+\.(?:${extensionAlternation}))(?=$|[\s"')<>\[\]{}|.,;:?!])`,
7+
"giu"
8+
)
9+
10+
const escapeChar = String.fromCodePoint(0x1B)
11+
const bellChar = String.fromCodePoint(0x07)
12+
13+
const buildAnsiPattern = (source: string): RegExp => new RegExp(source, "gu")
14+
15+
const ansiCsiPattern = buildAnsiPattern(String.raw`${escapeChar}\[[0-?]*[ -/]*[@-~]`)
16+
const ansiOscPattern = buildAnsiPattern(String.raw`${escapeChar}\][\s\S]*?(?:${bellChar}|${escapeChar}\\)`)
17+
const ansiOtherEscapePattern = buildAnsiPattern(`${escapeChar}.`)
18+
19+
export type TerminalImagePathMatch = {
20+
readonly endIndex: number
21+
readonly path: string
22+
readonly startIndex: number
23+
}
24+
25+
export const stripTerminalAnsi = (text: string): string =>
26+
text.replace(ansiOscPattern, "").replace(ansiCsiPattern, "").replace(ansiOtherEscapePattern, "")
27+
28+
export const detectTerminalImagePathMatches = (text: string): ReadonlyArray<TerminalImagePathMatch> => {
29+
const plainText = stripTerminalAnsi(text)
30+
const matches: Array<TerminalImagePathMatch> = []
31+
for (const match of plainText.matchAll(imagePathPattern)) {
32+
const candidate = match[1]
33+
if (candidate !== undefined && candidate.length > 0) {
34+
const fullMatch = match[0]
35+
const fullStartIndex = match.index
36+
const startIndex = fullStartIndex + fullMatch.lastIndexOf(candidate)
37+
matches.push({
38+
endIndex: startIndex + candidate.length,
39+
path: candidate,
40+
startIndex
41+
})
42+
}
43+
}
44+
return matches
45+
}
46+
47+
export const detectTerminalImagePaths = (text: string): ReadonlyArray<string> => {
48+
const matches = new Set<string>()
49+
for (const match of detectTerminalImagePathMatches(text)) {
50+
matches.add(match.path)
51+
}
52+
return [...matches]
53+
}
54+
55+
export const isSupportedTerminalImagePath = (path: string): boolean => {
56+
const lower = path.toLowerCase()
57+
return supportedExtensions.some((extension) => lower.endsWith(`.${extension}`))
58+
}

0 commit comments

Comments
 (0)