Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
37 changes: 35 additions & 2 deletions packages/api/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@ import {
deleteTerminalSession,
getProjectTerminalSession,
listProjectTerminalSessions,
lookupTerminalSessionById
lookupTerminalSessionById,
readProjectTerminalImage
} from "./services/terminal-sessions.js"
import {
commitStateFromRequest,
Expand Down Expand Up @@ -223,6 +224,14 @@ const textResponse = (data: string, contentType: string, status = 200) =>
)
)

const binaryResponse = (data: Uint8Array, contentType: string, status = 200) =>
Effect.succeed(
HttpServerResponse.setStatus(
HttpServerResponse.uint8Array(data, { contentType, headers: noStoreHeaders }),
status
)
)

const activityJsonResponse = (data: unknown, status: number) =>
textResponse(JSON.stringify(data), "application/activity+json; charset=utf-8", status)

Expand Down Expand Up @@ -1134,7 +1143,31 @@ export const makeRouter = () => {
)
)

const withAgents = withProjectLifecycle.pipe(
const withProjectTerminalImages = withProjectLifecycle.pipe(
HttpRouter.get(
"/projects/by-key/:projectKey/terminal-sessions/:sessionId/image",
Effect.gen(function*(_) {
const { projectKey, sessionId } = yield* _(terminalSessionByProjectKeyParams)
const request = yield* _(HttpServerRequest.HttpServerRequest)
const imagePath = new URL(request.url, "http://localhost").searchParams.get("path") ?? ""
const project = yield* _(getProjectItemByKey(projectKey))
const result = yield* _(readProjectTerminalImage(project.projectDir, sessionId, imagePath))
return yield* _(binaryResponse(result.bytes, result.mediaType, 200))
}).pipe(Effect.catchAll(errorResponse))
),
HttpRouter.get(
"/projects/:projectId/terminal-sessions/:sessionId/image",
Effect.gen(function*(_) {
const { projectId, sessionId } = yield* _(terminalSessionParams)
const request = yield* _(HttpServerRequest.HttpServerRequest)
const imagePath = new URL(request.url, "http://localhost").searchParams.get("path") ?? ""
const result = yield* _(readProjectTerminalImage(projectId, sessionId, imagePath))
return yield* _(binaryResponse(result.bytes, result.mediaType, 200))
}).pipe(Effect.catchAll(errorResponse))
)
)

const withAgents = withProjectTerminalImages.pipe(
HttpRouter.post(
"/projects/:projectId/agents",
Effect.gen(function*(_) {
Expand Down
57 changes: 57 additions & 0 deletions packages/api/src/services/terminal-image-fetch-core.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
export type TerminalImageFetchPlan =
| {
readonly _tag: "InvalidTerminalImageFetch"
readonly message: string
}
| {
readonly _tag: "ValidTerminalImageFetch"
readonly containerPath: string
readonly mediaType: string
}

export const terminalImageFetchMaxBytes = 10 * 1024 * 1024

const supportedExtensionMediaTypes = new Map<string, string>([
["gif", "image/gif"],
["jpeg", "image/jpeg"],
["jpg", "image/jpeg"],
["png", "image/png"],
["webp", "image/webp"]
])

const controlCharRange = `${String.fromCodePoint(0)}-${String.fromCodePoint(0x1F)}`
const deleteChar = String.fromCodePoint(0x7F)
const invalidCharacterPattern = new RegExp(String.raw`[\s${controlCharRange}${deleteChar}]`, "u")
const traversalPattern = /(?:^|\/)(?:\.|\.\.)(?=\/|$)/u

const lowercaseExtension = (path: string): string | null => {
const lastDot = path.lastIndexOf(".")
if (lastDot < 0 || lastDot === path.length - 1) {
return null
}
return path.slice(lastDot + 1).toLowerCase()
}

export const planTerminalImageFetch = (path: string): TerminalImageFetchPlan => {
if (typeof path !== "string" || path.length === 0) {
return { _tag: "InvalidTerminalImageFetch", message: "Image path is required." }
}
if (!path.startsWith("/")) {
return { _tag: "InvalidTerminalImageFetch", message: "Image path must be absolute." }
}
if (invalidCharacterPattern.test(path)) {
return { _tag: "InvalidTerminalImageFetch", message: "Image path contains invalid characters." }
}
if (traversalPattern.test(path)) {
return { _tag: "InvalidTerminalImageFetch", message: "Image path must not contain '.' or '..' segments." }
}
const extension = lowercaseExtension(path)
if (extension === null) {
return { _tag: "InvalidTerminalImageFetch", message: "Image path must include a file extension." }
}
const mediaType = supportedExtensionMediaTypes.get(extension)
if (mediaType === undefined) {
return { _tag: "InvalidTerminalImageFetch", message: `Unsupported image extension: .${extension}` }
}
return { _tag: "ValidTerminalImageFetch", containerPath: path, mediaType }
}
118 changes: 117 additions & 1 deletion packages/api/src/services/terminal-sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,12 @@ import type { Duplex } from "node:stream"
import { WebSocket, WebSocketServer, type RawData } from "ws"

import type { TerminalSession, TerminalSessionStatus } from "../api/contracts.js"
import { ApiInternalError, ApiNotFoundError, describeUnknown } from "../api/errors.js"
import { ApiBadRequestError, ApiInternalError, ApiNotFoundError, describeUnknown } from "../api/errors.js"
import { emitProjectEvent } from "./events.js"
import {
planTerminalImageFetch,
terminalImageFetchMaxBytes
} from "./terminal-image-fetch-core.js"
import {
createTerminalImagePastePlan,
terminalImagePasteDirectory,
Expand Down Expand Up @@ -392,6 +396,91 @@ const writeBufferToProjectContainer = (
child.stdin.end(buffer)
})

const readBufferFromProjectContainer = (
containerName: string,
containerPath: string,
maxBytes: number
): Effect.Effect<Buffer, ApiInternalError | ApiBadRequestError | ApiNotFoundError> =>
Effect.async((resume) => {
const child = spawn(
"docker",
[
"exec",
"-u",
"dev",
containerName,
"cat",
"--",
containerPath
],
{
cwd: process.cwd(),
stdio: ["ignore", "pipe", "pipe"]
}
)
const stdoutChunks: Array<Buffer> = []
const stderrChunks: Array<Buffer> = []
let totalBytes = 0
let exceededLimit = false
let completed = false
const resumeOnce = (
effect: Effect.Effect<Buffer, ApiInternalError | ApiBadRequestError | ApiNotFoundError>
): void => {
if (completed) {
return
}
completed = true
resume(effect)
}
child.stdout.on("data", (chunk: Buffer | string) => {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
totalBytes += buffer.length
if (totalBytes > maxBytes) {
exceededLimit = true
try {
child.kill()
} catch {
// ignore — close handler will resume
}
return
}
stdoutChunks.push(buffer)
})
child.stderr.on("data", (chunk: Buffer | string) => {
stderrChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
})
child.on("error", (error) => {
resumeOnce(Effect.fail(new ApiInternalError({
message: `Failed to run docker exec for ${containerName}.`,
cause: error
})))
})
child.on("close", (exitCode) => {
if (exceededLimit) {
resumeOnce(Effect.fail(new ApiBadRequestError({
message: `Image exceeds maximum size of ${maxBytes} bytes.`
})))
return
}
if (exitCode === 0) {
resumeOnce(Effect.succeed(Buffer.concat(stdoutChunks)))
return
}
const stderr = Buffer.concat(stderrChunks).toString("utf8").trim()
if (/no such file|not a directory|not found/iu.test(stderr)) {
resumeOnce(Effect.fail(new ApiNotFoundError({
message: `Image not found at ${containerPath}.`
})))
return
}
resumeOnce(Effect.fail(new ApiInternalError({
message: stderr.length > 0
? `Failed to read image: ${stderr}`
: `Failed to read image; docker exec exited with code ${exitCode ?? "unknown"}.`
})))
})
})

const saveTerminalImagePaste = (
record: TerminalRecord,
payload: TerminalImagePastePayload
Expand Down Expand Up @@ -611,6 +700,33 @@ export const getProjectTerminalSession = (
return record.session
})

export const readProjectTerminalImage = (
projectId: string,
sessionId: string,
imagePath: string
): Effect.Effect<
{ readonly bytes: Buffer; readonly mediaType: string },
ApiBadRequestError | ApiInternalError | ApiNotFoundError
> =>
Effect.gen(function*(_) {
const record = records.get(sessionId)
if (record === undefined || record.projectId !== projectId) {
return yield* _(
Effect.fail(new ApiNotFoundError({ message: `Terminal session not found: ${sessionId}` }))
)
}
const plan = planTerminalImageFetch(imagePath)
if (plan._tag === "InvalidTerminalImageFetch") {
return yield* _(Effect.fail(new ApiBadRequestError({ message: plan.message })))
}
const bytes = yield* _(readBufferFromProjectContainer(
record.projectContainerName,
plan.containerPath,
terminalImageFetchMaxBytes
))
return { bytes, mediaType: plan.mediaType }
})

export const lookupTerminalSessionById = (
sessionId: string
): Effect.Effect<
Expand Down
65 changes: 65 additions & 0 deletions packages/api/tests/terminal-image-fetch-core.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, expect, it } from "@effect/vitest"

import { planTerminalImageFetch } from "../src/services/terminal-image-fetch-core.js"

describe("terminal image fetch core", () => {
it("accepts an absolute path with a supported image extension", () => {
expect(planTerminalImageFetch("/tmp/issue232-main.png")).toEqual({
_tag: "ValidTerminalImageFetch",
containerPath: "/tmp/issue232-main.png",
mediaType: "image/png"
})
})

it("maps each supported extension to its media type", () => {
expect(planTerminalImageFetch("/a.jpg")).toMatchObject({ mediaType: "image/jpeg" })
expect(planTerminalImageFetch("/a.jpeg")).toMatchObject({ mediaType: "image/jpeg" })
expect(planTerminalImageFetch("/a.gif")).toMatchObject({ mediaType: "image/gif" })
expect(planTerminalImageFetch("/a.webp")).toMatchObject({ mediaType: "image/webp" })
expect(planTerminalImageFetch("/a.PNG")).toMatchObject({ mediaType: "image/png" })
})

it("rejects an empty path", () => {
expect(planTerminalImageFetch("")).toEqual({
_tag: "InvalidTerminalImageFetch",
message: "Image path is required."
})
})

it("rejects a relative path", () => {
expect(planTerminalImageFetch("tmp/photo.png")).toEqual({
_tag: "InvalidTerminalImageFetch",
message: "Image path must be absolute."
})
})

it("rejects whitespace and control characters", () => {
expect(planTerminalImageFetch("/tmp/has space.png")).toMatchObject({
_tag: "InvalidTerminalImageFetch"
})
expect(planTerminalImageFetch("/tmp/has\nnewline.png")).toMatchObject({
_tag: "InvalidTerminalImageFetch"
})
})

it("rejects parent-directory and current-directory traversal segments", () => {
expect(planTerminalImageFetch("/tmp/../etc/photo.png")).toMatchObject({
_tag: "InvalidTerminalImageFetch"
})
expect(planTerminalImageFetch("/tmp/./photo.png")).toMatchObject({
_tag: "InvalidTerminalImageFetch"
})
})

it("rejects unsupported extensions", () => {
expect(planTerminalImageFetch("/tmp/file.bmp")).toMatchObject({
_tag: "InvalidTerminalImageFetch"
})
expect(planTerminalImageFetch("/tmp/file")).toMatchObject({
_tag: "InvalidTerminalImageFetch"
})
expect(planTerminalImageFetch("/tmp/file.")).toMatchObject({
_tag: "InvalidTerminalImageFetch"
})
})
})
4 changes: 1 addition & 3 deletions packages/app/src/web/panel-terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -638,9 +638,7 @@ export const TerminalPanel = (
session={session}
status={status}
/>
<div
style={terminalBodyFrameStyle(compactTypingMode, mobileMode)}
>
<div style={terminalBodyFrameStyle(compactTypingMode, mobileMode)}>
<div ref={hostRef} style={terminalHostStyle} />
{hasBodyContent ? <div style={terminalBodyContentStyle}>{bodyContent}</div> : null}
</div>
Expand Down
58 changes: 58 additions & 0 deletions packages/app/src/web/terminal-image-paths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
const supportedExtensions: ReadonlyArray<string> = ["png", "jpg", "jpeg", "gif", "webp"]

const extensionAlternation = supportedExtensions.join("|")

const imagePathPattern = new RegExp(
String.raw`(?:^|[\s"'(<>\[\]{}|])(/[^\s"'(<>\[\]{}|\\]+\.(?:${extensionAlternation}))(?=$|[\s"')<>\[\]{}|.,;:?!])`,
"giu"
)

const escapeChar = String.fromCodePoint(0x1B)
const bellChar = String.fromCodePoint(0x07)

const buildAnsiPattern = (source: string): RegExp => new RegExp(source, "gu")

const ansiCsiPattern = buildAnsiPattern(String.raw`${escapeChar}\[[0-?]*[ -/]*[@-~]`)
const ansiOscPattern = buildAnsiPattern(String.raw`${escapeChar}\][\s\S]*?(?:${bellChar}|${escapeChar}\\)`)
const ansiOtherEscapePattern = buildAnsiPattern(`${escapeChar}.`)

export type TerminalImagePathMatch = {
readonly endIndex: number
readonly path: string
readonly startIndex: number
}

export const stripTerminalAnsi = (text: string): string =>
text.replace(ansiOscPattern, "").replace(ansiCsiPattern, "").replace(ansiOtherEscapePattern, "")

export const detectTerminalImagePathMatches = (text: string): ReadonlyArray<TerminalImagePathMatch> => {
const plainText = stripTerminalAnsi(text)
const matches: Array<TerminalImagePathMatch> = []
for (const match of plainText.matchAll(imagePathPattern)) {
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
})
}
}
return matches
}

export const detectTerminalImagePaths = (text: string): ReadonlyArray<string> => {
const matches = new Set<string>()
for (const match of detectTerminalImagePathMatches(text)) {
matches.add(match.path)
}
return [...matches]
}

export const isSupportedTerminalImagePath = (path: string): boolean => {
const lower = path.toLowerCase()
return supportedExtensions.some((extension) => lower.endsWith(`.${extension}`))
}
Loading