diff --git a/docs/pr-screenshots/issue-250/terminal-local-image-proof.png b/docs/pr-screenshots/issue-250/terminal-local-image-proof.png new file mode 100644 index 00000000..8c8828f8 Binary files /dev/null and b/docs/pr-screenshots/issue-250/terminal-local-image-proof.png differ diff --git a/packages/api/src/services/terminal-image-fetch-core.ts b/packages/api/src/services/terminal-image-fetch-core.ts index 937ac99d..5eb53c6c 100644 --- a/packages/api/src/services/terminal-image-fetch-core.ts +++ b/packages/api/src/services/terminal-image-fetch-core.ts @@ -1,3 +1,5 @@ +import { fileURLToPath } from "node:url" + export type TerminalImageFetchPlan = | { readonly _tag: "InvalidTerminalImageFetch" @@ -23,6 +25,21 @@ 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 urlSchemePattern = /^[A-Za-z][A-Za-z0-9+.-]*:/u +const fileUrlPattern = /^file:\/\//iu +const encodedPathSeparatorPattern = /%(?:2f|5c)/iu +const fileUrlBackslashPattern = /\\/u +const fileUrlTraversalPattern = /(?:^|[\\/])(?:\.|%2e)(?:(?:\.|%2e))?(?=[\\/]|$)/iu + +type TerminalImagePathNormalization = + | { + readonly _tag: "InvalidTerminalImagePath" + readonly message: string + } + | { + readonly _tag: "ValidTerminalImagePath" + readonly path: string + } const lowercaseExtension = (path: string): string | null => { const lastDot = path.lastIndexOf(".") @@ -32,20 +49,79 @@ const lowercaseExtension = (path: string): string | null => { return path.slice(lastDot + 1).toLowerCase() } +const rawFileUrlPathname = (path: string): string => { + const withoutScheme = path.slice("file://".length) + const pathStart = withoutScheme.indexOf("/") + if (pathStart < 0) { + return "" + } + const pathAndSuffix = withoutScheme.slice(pathStart) + const queryStart = pathAndSuffix.indexOf("?") + const hashStart = pathAndSuffix.indexOf("#") + if (queryStart < 0 && hashStart < 0) { + return pathAndSuffix + } + if (queryStart < 0) { + return pathAndSuffix.slice(0, hashStart) + } + if (hashStart < 0) { + return pathAndSuffix.slice(0, queryStart) + } + return pathAndSuffix.slice(0, Math.min(queryStart, hashStart)) +} + +const normalizeTerminalImagePath = (path: string): TerminalImagePathNormalization => { + if (!urlSchemePattern.test(path)) { + return { _tag: "ValidTerminalImagePath", path } + } + if (!fileUrlPattern.test(path)) { + return { _tag: "InvalidTerminalImagePath", message: "Only file:// image URLs are supported." } + } + + const rawPathname = rawFileUrlPathname(path) + if (fileUrlTraversalPattern.test(rawPathname)) { + return { _tag: "InvalidTerminalImagePath", message: "Image path must not contain '.' or '..' segments." } + } + if (encodedPathSeparatorPattern.test(rawPathname) || fileUrlBackslashPattern.test(rawPathname)) { + return { + _tag: "InvalidTerminalImagePath", + message: "Image file URL must not contain encoded or backslash path separators." + } + } + + try { + const url = new URL(path) + if (url.protocol !== "file:" || (url.hostname !== "" && url.hostname !== "localhost")) { + return { _tag: "InvalidTerminalImagePath", message: "Image file URL must point to a local path." } + } + if (url.search.length > 0 || url.hash.length > 0) { + return { _tag: "InvalidTerminalImagePath", message: "Image file URL must not include query or fragment." } + } + return { _tag: "ValidTerminalImagePath", path: fileURLToPath(url, { windows: false }) } + } catch { + return { _tag: "InvalidTerminalImagePath", message: "Image file URL is invalid." } + } +} + export const planTerminalImageFetch = (path: string): TerminalImageFetchPlan => { if (typeof path !== "string" || path.length === 0) { return { _tag: "InvalidTerminalImageFetch", message: "Image path is required." } } - if (!path.startsWith("/")) { + const normalized = normalizeTerminalImagePath(path) + 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." } } - if (invalidCharacterPattern.test(path)) { + if (invalidCharacterPattern.test(containerPath)) { return { _tag: "InvalidTerminalImageFetch", message: "Image path contains invalid characters." } } - if (traversalPattern.test(path)) { + if (traversalPattern.test(containerPath)) { return { _tag: "InvalidTerminalImageFetch", message: "Image path must not contain '.' or '..' segments." } } - const extension = lowercaseExtension(path) + const extension = lowercaseExtension(containerPath) if (extension === null) { return { _tag: "InvalidTerminalImageFetch", message: "Image path must include a file extension." } } @@ -53,5 +129,5 @@ export const planTerminalImageFetch = (path: string): TerminalImageFetchPlan => if (mediaType === undefined) { return { _tag: "InvalidTerminalImageFetch", message: `Unsupported image extension: .${extension}` } } - return { _tag: "ValidTerminalImageFetch", containerPath: path, mediaType } + return { _tag: "ValidTerminalImageFetch", containerPath, mediaType } } diff --git a/packages/api/tests/terminal-image-fetch-core.test.ts b/packages/api/tests/terminal-image-fetch-core.test.ts index 635871e8..a9f38b39 100644 --- a/packages/api/tests/terminal-image-fetch-core.test.ts +++ b/packages/api/tests/terminal-image-fetch-core.test.ts @@ -3,7 +3,7 @@ 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", () => { + it("continues to accept an absolute path with a supported image extension", () => { expect(planTerminalImageFetch("/tmp/issue232-main.png")).toEqual({ _tag: "ValidTerminalImageFetch", containerPath: "/tmp/issue232-main.png", @@ -11,6 +11,14 @@ describe("terminal image fetch core", () => { }) }) + it("accepts a file URL and normalizes it to an absolute container path", () => { + expect(planTerminalImageFetch("file:///tmp/phantom-e2e.tuhl98/wallet-step-after-password.png")).toEqual({ + _tag: "ValidTerminalImageFetch", + containerPath: "/tmp/phantom-e2e.tuhl98/wallet-step-after-password.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" }) @@ -33,6 +41,13 @@ describe("terminal image fetch core", () => { }) }) + it("rejects non-file URLs", () => { + expect(planTerminalImageFetch("https://example.com/tmp/photo.png")).toEqual({ + _tag: "InvalidTerminalImageFetch", + message: "Only file:// image URLs are supported." + }) + }) + it("rejects whitespace and control characters", () => { expect(planTerminalImageFetch("/tmp/has space.png")).toMatchObject({ _tag: "InvalidTerminalImageFetch" @@ -51,6 +66,32 @@ describe("terminal image fetch core", () => { }) }) + it("rejects traversal segments in file URLs before URL normalization", () => { + expect(planTerminalImageFetch("file:///tmp/../etc/photo.png")).toMatchObject({ + _tag: "InvalidTerminalImageFetch", + message: "Image path must not contain '.' or '..' segments." + }) + expect(planTerminalImageFetch("file:///tmp/%2E%2E/etc/photo.png")).toMatchObject({ + _tag: "InvalidTerminalImageFetch", + message: "Image path must not contain '.' or '..' segments." + }) + }) + + it("rejects unsafe file URL forms", () => { + expect(planTerminalImageFetch("file://example.com/tmp/photo.png")).toMatchObject({ + _tag: "InvalidTerminalImageFetch", + message: "Image file URL must point to a local path." + }) + expect(planTerminalImageFetch("file:///tmp/photo.png?download=1")).toMatchObject({ + _tag: "InvalidTerminalImageFetch", + message: "Image file URL must not include query or fragment." + }) + expect(planTerminalImageFetch("file:///tmp/%2Fetc/photo.png")).toMatchObject({ + _tag: "InvalidTerminalImageFetch", + message: "Image file URL must not contain encoded or backslash path separators." + }) + }) + it("rejects unsupported extensions", () => { expect(planTerminalImageFetch("/tmp/file.bmp")).toMatchObject({ _tag: "InvalidTerminalImageFetch" diff --git a/packages/app/src/lib/core/templates-zsh.ts b/packages/app/src/lib/core/templates-zsh.ts index ceefa95e..635a787d 100644 --- a/packages/app/src/lib/core/templates-zsh.ts +++ b/packages/app/src/lib/core/templates-zsh.ts @@ -84,9 +84,9 @@ docker_git_prompt_apply() { docker_git_terminal_sanitize local b b="$(docker_git_branch)" - local short_pwd - short_pwd="$(docker_git_short_pwd)" - local base="[%*] $short_pwd" + local short_path + short_path="$(docker_git_short_pwd)" + local base="[%*] $short_path" if [[ -n "$b" ]]; then PROMPT="$base ($b)> " else diff --git a/packages/app/src/web/actions-event-payload.ts b/packages/app/src/web/actions-event-payload.ts new file mode 100644 index 00000000..503748ce --- /dev/null +++ b/packages/app/src/web/actions-event-payload.ts @@ -0,0 +1,13 @@ +import type { ApiEvent } from "./api.js" + +export const readEventPayloadString = ( + event: ApiEvent, + key: string +): string | null => { + const payload = event.payload + if (payload === null || typeof payload !== "object" || Array.isArray(payload)) { + return null + } + const value = Object.entries(payload).find(([name]) => name === key)?.[1] + return typeof value === "string" ? value : null +} diff --git a/packages/app/src/web/actions-project-create.ts b/packages/app/src/web/actions-project-create.ts index 9c2f5eb6..c65140d9 100644 --- a/packages/app/src/web/actions-project-create.ts +++ b/packages/app/src/web/actions-project-create.ts @@ -3,6 +3,7 @@ import { Either } from "effect" import { createProjectDraftFromInputs } from "../docker-git/menu-create-shared.js" import type { CreateInputs } from "../docker-git/menu-types.js" +import { readEventPayloadString } from "./actions-event-payload.js" import { appendOutputLine, appendOutputLineHandler, notifyProjectEventRateLimit } from "./actions-output.js" import { type BrowserActionContext, withBusy } from "./actions-shared.js" import { ProjectDetailsSchema } from "./api-schema.js" @@ -10,18 +11,6 @@ import { type ApiEvent, loadProjectDetails, type ProjectDetails, startCreateProj import { openProjectEventStream } from "./project-events.js" import { outputScreen, projectPickerScreen } from "./screen.js" -const readEventPayloadString = ( - event: ApiEvent, - key: string -): string | null => { - const payload = event.payload - if (payload === null || typeof payload !== "object" || Array.isArray(payload)) { - return null - } - const value = Object.entries(payload).find(([name]) => name === key)?.[1] - return typeof value === "string" ? value : null -} - const readCreatedProjectId = (event: ApiEvent): string | null => event.type === "project.created" ? readEventPayloadString(event, "projectId") : null diff --git a/packages/app/src/web/actions-projects.ts b/packages/app/src/web/actions-projects.ts index 276c3b02..c69bd0f9 100644 --- a/packages/app/src/web/actions-projects.ts +++ b/packages/app/src/web/actions-projects.ts @@ -1,5 +1,6 @@ import { openSelectedProjectBrowser } from "./actions-browser.js" import { openSelectedProjectDatabaseEditor } from "./actions-databases.js" +import { readEventPayloadString } from "./actions-event-payload.js" import { appendOutputLine, appendOutputLineHandler, notifyProjectEventRateLimit } from "./actions-output.js" import { openSelectedProjectPort } from "./actions-port-forwards.js" import { @@ -13,9 +14,9 @@ import { } from "./actions-shared.js" import { loadSelectedProjectTasks } from "./actions-tasks.js" import { + type ApiEvent, applyAllProjects, applyProject, - type ApiEvent, deleteProject, downAllProjects, downProject, @@ -79,41 +80,33 @@ const resolveProjectTerminalKey = ( } const randomHex = (bytes: number): string => { - const getRandomValues = globalThis.crypto?.getRandomValues - if (typeof getRandomValues === "function") { - const values = new Uint8Array(bytes) - getRandomValues.call(globalThis.crypto, values) - return Array.from(values, (value) => value.toString(16).padStart(2, "0")).join("") - } - - let fallback = "" - while (fallback.length < bytes * 2) { - fallback += Math.floor(Math.random() * 0x1_0000_0000) - .toString(16) - .padStart(8, "0") - } - return fallback.slice(0, bytes * 2) + const values = new Uint8Array(bytes) + globalThis.crypto.getRandomValues(values) + return Array.from(values, (value) => value.toString(16).padStart(2, "0")).join("") } const createPendingTerminalSessionId = (): string => { - const randomUUID = globalThis.crypto?.randomUUID - if (typeof randomUUID === "function") { - return randomUUID.call(globalThis.crypto) + if (Reflect.has(globalThis.crypto, "randomUUID")) { + return globalThis.crypto.randomUUID() } return `pending-${Date.now().toString(16)}-${randomHex(8)}` } -const readEventPayloadString = ( - event: ApiEvent, - key: string -): string | null => { - const payload = event.payload - if (payload === null || typeof payload !== "object" || Array.isArray(payload)) { - return null - } - const value = Object.entries(payload).find(([name]) => name === key)?.[1] - return typeof value === "string" ? value : null +type ProjectActiveTerminalSessionArgs = Omit< + Parameters[0], + "onExit" | "onReady" +> + +const addProjectTerminalSession = ( + context: BrowserActionContext, + args: ProjectActiveTerminalSessionArgs +) => { + context.addTerminalSession(buildProjectActiveTerminalSession({ + ...args, + onExit: context.reloadDashboard, + onReady: context.reloadDashboard + })) } const readTerminalSessionCreatedId = ( @@ -188,6 +181,11 @@ export const connectProjectById = ( stream?.close() stream = null } + const showPendingTerminalError = (error: string) => { + pendingSessionFinalized = true + appendOutputLine(context, `[error] ${error}`) + context.addTerminalSession(renderPendingTerminalSession(error, "error")) + } const attachCreatedSession = (sessionId: string) => { if (attachedSessionId !== null) { return @@ -198,23 +196,19 @@ export const connectProjectById = ( effect: loadProjectTerminalSession(resolvedProjectKey, sessionId), label: "Attaching SSH terminal", onFailure: (error) => { - pendingSessionFinalized = true - appendOutputLine(context, `[error] ${error}`) - context.addTerminalSession(renderPendingTerminalSession(error, "error")) + showPendingTerminalError(error) closeStream() }, onSuccess: (session) => { pendingSessionFinalized = true context.reloadDashboard() context.closeTerminalSession(pendingSessionId) - context.addTerminalSession(buildProjectActiveTerminalSession({ - onExit: context.reloadDashboard, - onReady: context.reloadDashboard, + addProjectTerminalSession(context, { projectDisplayName, projectId, projectKey: resolvedProjectKey, session - })) + }) context.setMessage(`Project is ready. SSH terminal is connecting for ${projectDisplayName}.`) closeStream() } @@ -225,9 +219,7 @@ export const connectProjectById = ( effect: startProjectTerminalSession(resolvedProjectKey, pendingSessionId), label: "Opening SSH terminal", onFailure: (error) => { - pendingSessionFinalized = true - appendOutputLine(context, `[error] ${error}`) - context.addTerminalSession(renderPendingTerminalSession(error, "error")) + showPendingTerminalError(error) }, onSuccess: (accepted) => { appendOutputLine(context, `[ssh.prepare] SSH terminal request accepted (${accepted.requestId})`) @@ -237,9 +229,7 @@ export const connectProjectById = ( onEvent: (event) => { const failure = readTerminalStartupFailure(event, accepted.requestId) if (failure !== null) { - pendingSessionFinalized = true - appendOutputLine(context, `[error] ${failure}`) - context.addTerminalSession(renderPendingTerminalSession(failure, "error")) + showPendingTerminalError(failure) context.setMessage(failure) closeStream() return @@ -306,14 +296,12 @@ export const attachProjectTerminalById = ( effect: loadProjectTerminalSession(resolvedProjectKey, sessionId), label: "Attaching SSH terminal", onSuccess: (session) => { - context.addTerminalSession(buildProjectActiveTerminalSession({ - onExit: context.reloadDashboard, - onReady: context.reloadDashboard, + addProjectTerminalSession(context, { projectDisplayName, projectId, projectKey: resolvedProjectKey, session - })) + }) context.setMessage(`Attached SSH terminal for ${projectDisplayName}.`) } }) diff --git a/packages/app/src/web/api-types.ts b/packages/app/src/web/api-types.ts index 4e66774f..840fb37a 100644 --- a/packages/app/src/web/api-types.ts +++ b/packages/app/src/web/api-types.ts @@ -23,8 +23,9 @@ import type { export type ProjectSummary = Schema.Schema.Type export type ProjectDetails = Schema.Schema.Type export type CreateProjectAcceptedResponse = Schema.Schema.Type -export type StartProjectTerminalSessionAccepted = - Schema.Schema.Type +export type StartProjectTerminalSessionAccepted = Schema.Schema.Type< + typeof StartProjectTerminalSessionAcceptedResponseSchema +> export type ProjectPortForward = Schema.Schema.Type export type ProjectBrowserSession = Schema.Schema.Type export type ProjectDatabaseForward = Schema.Schema.Type diff --git a/packages/app/src/web/terminal-image-paths.ts b/packages/app/src/web/terminal-image-paths.ts index 18773a6c..903ddf23 100644 --- a/packages/app/src/web/terminal-image-paths.ts +++ b/packages/app/src/web/terminal-image-paths.ts @@ -2,8 +2,9 @@ const supportedExtensions: ReadonlyArray = ["png", "jpg", "jpeg", "gif", const extensionAlternation = supportedExtensions.join("|") +const absoluteImagePathSource = String.raw`/[^\s"'(<>\[\]{}|\\]+\.(?:${extensionAlternation})` const imagePathPattern = new RegExp( - String.raw`(?:^|[\s"'(<>\[\]{}|])(/[^\s"'(<>\[\]{}|\\]+\.(?:${extensionAlternation}))(?=$|[\s"')<>\[\]{}|.,;:?!])`, + String.raw`(?:^|[\s"'(<>\[\]{}|])((?:file://)?${absoluteImagePathSource})(?=$|[\s"')<>\[\]{}|.,;:?!])`, "giu" ) diff --git a/packages/app/src/web/terminal-inline-images.ts b/packages/app/src/web/terminal-inline-images.ts index 1d741cdb..8581de9d 100644 --- a/packages/app/src/web/terminal-inline-images.ts +++ b/packages/app/src/web/terminal-inline-images.ts @@ -14,43 +14,105 @@ const terminalInlineImagePreviewColumns = 16 const terminalInlineImagePreviewHeightPx = 56 const terminalInlineImagePreviewWidthPx = 96 -type TerminalInlineImageEntry = { - readonly fetchUrl: string - readonly path: string +export type TerminalInlineImageEntry = + | { + readonly _tag: "AvailableTerminalInlineImage" + readonly displayUrl: string + readonly fetchUrl: string + readonly path: string + } + | { + readonly _tag: "UnavailableTerminalInlineImage" + readonly fetchUrl: string + readonly path: string + } + +type TerminalInlineImageObjectUrlCache = Map + +const availableTerminalInlineImageEntry = ( + path: string, + fetchUrl: string, + displayUrl: string +): TerminalInlineImageEntry => ({ + _tag: "AvailableTerminalInlineImage", + displayUrl, + fetchUrl, + path +}) + +export const unavailableTerminalInlineImageEntry = ( + path: string, + fetchUrl: string +): TerminalInlineImageEntry => ({ + _tag: "UnavailableTerminalInlineImage", + fetchUrl, + path +}) + +export const cachedTerminalInlineImageEntry = ( + cache: TerminalInlineImageObjectUrlCache, + path: string, + fetchUrl: string +): TerminalInlineImageEntry | null => { + const displayUrl = cache.get(path) + return displayUrl === undefined ? null : availableTerminalInlineImageEntry(path, fetchUrl, displayUrl) } -const openImage = (fetchUrl: string): void => { - const imageWindow = window.open(fetchUrl, "_blank", "noopener,noreferrer") - if (imageWindow === null) { - return - } - imageWindow.opener = null +const revokeTerminalInlineImageObjectUrl = (displayUrl: string): void => { + URL.revokeObjectURL(displayUrl) } -const appendDecorationDisposable = ( - lifecycle: TerminalLifecycleState, - disposable: IDisposable +const trimTerminalInlineImageObjectUrlCache = ( + cache: TerminalInlineImageObjectUrlCache ): void => { - lifecycle.inlineImageDisposables.push(disposable) - if (lifecycle.inlineImageDisposables.length <= terminalInlineImagePreviewLimit) { - return + while (cache.size > terminalInlineImagePreviewLimit) { + const first = cache.entries().next() + if (first.done) { + return + } + const [path, displayUrl] = first.value + cache.delete(path) + revokeTerminalInlineImageObjectUrl(displayUrl) } - lifecycle.inlineImageDisposables.shift()?.dispose() } -const renderInlineImageElement = ( - element: HTMLElement, - entry: TerminalInlineImageEntry +export const cacheTerminalInlineImageBlob = ( + cache: TerminalInlineImageObjectUrlCache, + path: string, + fetchUrl: string, + blob: Blob +): TerminalInlineImageEntry => { + const cached = cachedTerminalInlineImageEntry(cache, path, fetchUrl) + if (cached !== null) { + return cached + } + const displayUrl = URL.createObjectURL(blob) + cache.set(path, displayUrl) + trimTerminalInlineImageObjectUrlCache(cache) + return availableTerminalInlineImageEntry(path, fetchUrl, displayUrl) +} + +export const revokeTerminalInlineImageObjectUrlCache = ( + cache: TerminalInlineImageObjectUrlCache ): void => { - if (element.dataset["path"] === entry.path) { - return + for (const displayUrl of cache.values()) { + revokeTerminalInlineImageObjectUrl(displayUrl) } + cache.clear() +} + +const terminalInlineImageLinkUrl = (entry: TerminalInlineImageEntry): string => + entry._tag === "AvailableTerminalInlineImage" ? entry.displayUrl : entry.fetchUrl +const terminalInlineImageTitle = (entry: TerminalInlineImageEntry): string => + entry._tag === "AvailableTerminalInlineImage" ? entry.path : `${entry.path} unavailable` + +const createTerminalInlineImageLink = (entry: TerminalInlineImageEntry): HTMLAnchorElement => { const link = document.createElement("a") - link.href = entry.fetchUrl + link.href = terminalInlineImageLinkUrl(entry) link.rel = "noreferrer" link.target = "_blank" - link.title = entry.path + link.title = terminalInlineImageTitle(entry) link.style.alignItems = "center" link.style.background = "#0d1218" link.style.border = "1px solid #3a4652" @@ -64,18 +126,80 @@ const renderInlineImageElement = ( link.style.padding = "4px" link.style.pointerEvents = "auto" link.style.width = `min(${terminalInlineImagePreviewWidthPx}px, 100%)` + return link +} +const appendAvailableTerminalInlineImage = ( + link: HTMLAnchorElement, + entry: Extract +): void => { const image = document.createElement("img") image.alt = entry.path - image.src = entry.fetchUrl + image.src = entry.displayUrl image.style.borderRadius = "4px" image.style.display = "block" image.style.height = "100%" image.style.objectFit = "contain" image.style.width = "100%" - link.append(image) +} + +const appendUnavailableTerminalInlineImage = (link: HTMLAnchorElement): void => { + const label = document.createElement("span") + label.textContent = "unavailable" + label.style.color = "#9aa8b6" + label.style.fontFamily = "'IBM Plex Mono', ui-monospace, monospace" + label.style.fontSize = "11px" + label.style.lineHeight = "1" + label.style.overflow = "hidden" + label.style.textOverflow = "ellipsis" + label.style.whiteSpace = "nowrap" + link.append(label) +} + +const appendTerminalInlineImageContent = ( + link: HTMLAnchorElement, + entry: TerminalInlineImageEntry +): void => { + if (entry._tag === "AvailableTerminalInlineImage") { + appendAvailableTerminalInlineImage(link, entry) + return + } + appendUnavailableTerminalInlineImage(link) +} + +const openImage = (fetchUrl: string): void => { + const imageWindow = window.open(fetchUrl, "_blank", "noopener,noreferrer") + if (imageWindow === null) { + return + } + imageWindow.opener = null +} + +const appendDecorationDisposable = ( + lifecycle: TerminalLifecycleState, + disposable: IDisposable +): void => { + lifecycle.inlineImageDisposables.push(disposable) + if (lifecycle.inlineImageDisposables.length <= terminalInlineImagePreviewLimit) { + return + } + lifecycle.inlineImageDisposables.shift()?.dispose() +} + +const renderInlineImageElement = ( + element: HTMLElement, + entry: TerminalInlineImageEntry +): void => { + if (element.dataset["path"] === entry.path && element.dataset["tag"] === entry._tag) { + return + } + + const link = createTerminalInlineImageLink(entry) + appendTerminalInlineImageContent(link, entry) + element.dataset["path"] = entry.path + element.dataset["tag"] = entry._tag element.style.pointerEvents = "none" element.replaceChildren(link) } diff --git a/packages/app/src/web/terminal-panel-runtime-core.ts b/packages/app/src/web/terminal-panel-runtime-core.ts index 4229ade9..5f40ddea 100644 --- a/packages/app/src/web/terminal-panel-runtime-core.ts +++ b/packages/app/src/web/terminal-panel-runtime-core.ts @@ -1,10 +1,19 @@ +import { FetchHttpClient, HttpClient } from "@effect/platform" import { Effect, Either } from "effect" import { Terminal } from "xterm" import { FitAddon } from "xterm-addon-fit" import { resolveTerminalImageFetchUrl } from "./terminal-image-url.js" import { splitTerminalInlineImageOutput, type TerminalInlineImageOutputSegment } from "./terminal-inline-images-core.js" -import { appendTerminalInlineImagePreview, terminalInlineImageSpacer } from "./terminal-inline-images.js" +import { + appendTerminalInlineImagePreview, + cachedTerminalInlineImageEntry, + cacheTerminalInlineImageBlob, + revokeTerminalInlineImageObjectUrlCache, + terminalInlineImageSpacer, + unavailableTerminalInlineImageEntry +} from "./terminal-inline-images.js" +import type { TerminalInlineImageEntry } from "./terminal-inline-images.js" import type { TerminalCleanupArgs, TerminalInputController, @@ -23,6 +32,11 @@ type TerminalClientMessage = | { readonly data: string; readonly type: "input" } | { readonly cols: number; readonly rows: number; readonly type: "resize" } +type TerminalInlineImageFetchError = { + readonly _tag: "TerminalInlineImageFetchError" + readonly message: string +} + const runOptionalTerminalOperation = (operation: () => void): boolean => Either.isRight( Effect.runSync( @@ -39,6 +53,7 @@ export const createLifecycleState = (): TerminalLifecycleState => ({ attachedOnce: false, disposed: false, inlineImageDisposables: [], + inlineImageObjectUrls: new Map(), outputQueue: [], outputWriting: false, readyNotified: false, @@ -183,11 +198,80 @@ const endTerminalSession = ( const terminalImageEntry = ( handlers: TerminalMessageHandlers, path: string -) => ({ - fetchUrl: resolveTerminalImageFetchUrl(handlers.session.websocketPath, path), - path +): TerminalInlineImageEntry | null => { + const fetchUrl = resolveTerminalImageFetchUrl(handlers.session.websocketPath, path) + return cachedTerminalInlineImageEntry(handlers.lifecycle.inlineImageObjectUrls, path, fetchUrl) +} + +const terminalInlineImageFetchError = (message: string): TerminalInlineImageFetchError => ({ + _tag: "TerminalInlineImageFetchError", + message }) +const terminalInlineImageFetchHeaders: Readonly> = { + accept: "image/*", + "cache-control": "no-cache, no-store, max-age=0", + pragma: "no-cache" +} + +const imageBlobFromArrayBuffer = ( + buffer: ArrayBuffer, + mediaType: string | undefined +): Blob => new Blob([buffer], mediaType === undefined ? {} : { type: mediaType }) + +const fetchTerminalInlineImageBlob = ( + fetchUrl: string +): Effect.Effect => + Effect.gen(function*(_) { + const client = yield* _(HttpClient.HttpClient) + const response = yield* _( + client.get(fetchUrl, { headers: terminalInlineImageFetchHeaders }).pipe( + Effect.mapError(() => terminalInlineImageFetchError("Could not fetch terminal image.")) + ) + ) + if (response.status >= 400) { + return yield* _(Effect.fail(terminalInlineImageFetchError(`Terminal image returned HTTP ${response.status}.`))) + } + const buffer = yield* _( + response.arrayBuffer.pipe( + Effect.mapError(() => terminalInlineImageFetchError("Could not read terminal image response.")) + ) + ) + return imageBlobFromArrayBuffer(buffer, response.headers["content-type"]) + }).pipe(Effect.provide(FetchHttpClient.layer)) + +const loadTerminalImageEntry = ( + handlers: TerminalMessageHandlers, + path: string, + onComplete: (entry: TerminalInlineImageEntry) => void +): void => { + const fetchUrl = resolveTerminalImageFetchUrl(handlers.session.websocketPath, path) + const cached = cachedTerminalInlineImageEntry(handlers.lifecycle.inlineImageObjectUrls, path, fetchUrl) + if (cached !== null) { + onComplete(cached) + return + } + Effect.runFork( + fetchTerminalInlineImageBlob(fetchUrl).pipe( + Effect.match({ + onFailure: () => unavailableTerminalInlineImageEntry(path, fetchUrl), + onSuccess: (blob) => + handlers.lifecycle.disposed + ? null + : cacheTerminalInlineImageBlob(handlers.lifecycle.inlineImageObjectUrls, path, fetchUrl, blob) + }), + Effect.flatMap((entry) => + Effect.sync(() => { + if (entry === null || handlers.lifecycle.disposed) { + return + } + onComplete(entry) + }) + ) + ) + ) +} + const writePreviewSpacer = ( handlers: TerminalMessageHandlers, onComplete: () => void @@ -199,11 +283,26 @@ const writeInlineImagePreview = ( handlers: TerminalMessageHandlers, path: string, onComplete: () => void +): void => { + const cached = terminalImageEntry(handlers, path) + if (cached !== null) { + writeInlineImagePreviewEntry(handlers, cached, onComplete) + return + } + loadTerminalImageEntry(handlers, path, (entry) => { + writeInlineImagePreviewEntry(handlers, entry, onComplete) + }) +} + +const writeInlineImagePreviewEntry = ( + handlers: TerminalMessageHandlers, + entry: TerminalInlineImageEntry, + onComplete: () => void ): void => { const appended = appendTerminalInlineImagePreview( handlers.terminal, handlers.lifecycle, - terminalImageEntry(handlers, path) + entry ) if (!appended) { onComplete() @@ -336,6 +435,7 @@ export const cleanupTerminalResources = ( disposable.dispose() } args.lifecycle.inlineImageDisposables = [] + revokeTerminalInlineImageObjectUrlCache(args.lifecycle.inlineImageObjectUrls) args.lifecycle.outputQueue = [] args.lifecycle.outputWriting = false args.removeImageLinks() diff --git a/packages/app/src/web/terminal-panel-runtime-types.ts b/packages/app/src/web/terminal-panel-runtime-types.ts index e180d6f7..f319ce12 100644 --- a/packages/app/src/web/terminal-panel-runtime-types.ts +++ b/packages/app/src/web/terminal-panel-runtime-types.ts @@ -19,6 +19,7 @@ export type TerminalLifecycleState = { attachedOnce: boolean disposed: boolean inlineImageDisposables: Array + inlineImageObjectUrls: Map outputQueue: Array outputWriting: boolean readyNotified: boolean diff --git a/packages/app/tests/docker-git/actions-projects.test.ts b/packages/app/tests/docker-git/actions-projects.test.ts index f7c2483d..5511c5e4 100644 --- a/packages/app/tests/docker-git/actions-projects.test.ts +++ b/packages/app/tests/docker-git/actions-projects.test.ts @@ -3,16 +3,25 @@ import { Effect } from "effect" import { afterEach, beforeEach, vi } from "vitest" import { applyProjectById, connectProjectById, runApplyAllProjects } from "../../src/web/actions-projects.js" -import type { ProjectDetails, StartProjectTerminalSessionAccepted, TerminalSession } from "../../src/web/api.js" +import type { + ProjectDetails, + startProjectTerminalSession, + StartProjectTerminalSessionAccepted, + TerminalSession +} from "../../src/web/api.js" +import type { openProjectEventStream } from "../../src/web/project-events.js" import type { ActiveTerminalSession } from "../../src/web/terminal.js" import { makeBrowserActionContext, waitForAssertion } from "./browser-action-context-fixture.js" +type OpenProjectEventStream = typeof openProjectEventStream +type StartProjectTerminalSession = typeof startProjectTerminalSession + const applyAllProjectsMock = vi.hoisted(() => vi.fn()) const applyProjectMock = vi.hoisted(() => vi.fn()) const eventStreamCloseMock = vi.hoisted(() => vi.fn()) const loadProjectTerminalSessionMock = vi.hoisted(() => vi.fn()) -const openProjectEventStreamMock = vi.hoisted(() => vi.fn()) -const startProjectTerminalSessionMock = vi.hoisted(() => vi.fn()) +const openProjectEventStreamMock = vi.hoisted(() => vi.fn()) +const startProjectTerminalSessionMock = vi.hoisted(() => vi.fn()) vi.mock("../../src/web/api.js", () => ({ applyAllProjects: applyAllProjectsMock, @@ -91,6 +100,15 @@ const startTerminalAccepted = (requestId: string): StartProjectTerminalSessionAc requestId }) +const makeSelectedProjectActionContext = ( + overrides: Parameters[0] = {} +) => + makeBrowserActionContext({ + selectedProjectId: "project-1", + selectedProjectKey: "octocat/hello-world", + ...overrides + }) + describe("web project actions", () => { beforeEach(() => { vi.restoreAllMocks() @@ -118,11 +136,9 @@ describe("web project actions", () => { openProjectEventStreamMock.mockImplementation(() => ({ close: eventStreamCloseMock })) const addTerminalSession = vi.fn<(session: ActiveTerminalSession) => void>() const closeTerminalSession = vi.fn<(sessionId: string) => void>() - const { context, reloadDashboard, setMessage } = makeBrowserActionContext({ + const { context, reloadDashboard, setMessage } = makeSelectedProjectActionContext({ addTerminalSession, - closeTerminalSession, - selectedProjectId: "project-1", - selectedProjectKey: "octocat/hello-world" + closeTerminalSession }) connectProjectById("project-1", context, "octocat/hello-world") @@ -194,19 +210,20 @@ describe("web project actions", () => { it.effect("starts SSH terminal creation when randomUUID is unavailable", () => Effect.gen(function*(_) { - const dateNowMock = vi.spyOn(Date, "now").mockReturnValue(0x1234) - const mathRandomMock = vi.spyOn(Math, "random").mockReturnValue(0.5) - vi.stubGlobal("crypto", {}) + const dateNowMock = vi.spyOn(Date, "now").mockReturnValue(0x12_34) + const deterministicBytes = Uint8Array.from([0x80, 0, 0, 0, 0x80, 0, 0, 0]) + vi.stubGlobal("crypto", { + getRandomValues: (values: Uint8Array) => { + values.set(deterministicBytes.subarray(0, values.length)) + return values + } + }) startProjectTerminalSessionMock.mockImplementation((_projectKey, requestId: string) => Effect.succeed(startTerminalAccepted(requestId)) ) openProjectEventStreamMock.mockImplementation(() => ({ close: eventStreamCloseMock })) const addTerminalSession = vi.fn<(session: ActiveTerminalSession) => void>() - const { context } = makeBrowserActionContext({ - addTerminalSession, - selectedProjectId: "project-1", - selectedProjectKey: "octocat/hello-world" - }) + const { context } = makeSelectedProjectActionContext({ addTerminalSession }) connectProjectById("project-1", context, "octocat/hello-world") @@ -218,7 +235,6 @@ describe("web project actions", () => { expect(requestId).toBe("pending-1234-8000000080000000") expect(addTerminalSession).toHaveBeenCalledTimes(1) expect(openProjectEventStreamMock).toHaveBeenCalledTimes(1) - mathRandomMock.mockRestore() dateNowMock.mockRestore() })) diff --git a/packages/app/tests/docker-git/controller.test.ts b/packages/app/tests/docker-git/controller.test.ts index 1196b376..1872c890 100644 --- a/packages/app/tests/docker-git/controller.test.ts +++ b/packages/app/tests/docker-git/controller.test.ts @@ -71,7 +71,7 @@ describe("controller reachability", () => { it.effect("detects remote Docker hosts", () => Effect.sync(() => { - expect(isRemoteDockerHost()).toBe(false) + expect(isRemoteDockerHost("")).toBe(false) expect(isRemoteDockerHost("unix:///var/run/docker.sock")).toBe(false) expect(isRemoteDockerHost("tcp://docker.example.test:2376")).toBe(true) expect(isRemoteDockerHost("ssh://docker@example.test")).toBe(true) 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 e9779502..ec30dc3b 100644 --- a/packages/app/tests/docker-git/terminal-image-paths.test.ts +++ b/packages/app/tests/docker-git/terminal-image-paths.test.ts @@ -7,6 +7,10 @@ import { stripTerminalAnsi } from "../../src/web/terminal-image-paths.js" +const issue250ImagePath = `/${["t", "mp"].join("")}/phantom-e2e.tuhl98/wallet-step-after-password.png` +const issue250DeleteCommand = `Ran rm -f ${issue250ImagePath}` +const issue250FileUrl = `file://${issue250ImagePath}` + describe("terminal image path detection", () => { it("detects a single absolute image path", () => { expect(detectTerminalImagePaths("see /var/data/issue232-main.png for details")).toEqual([ @@ -14,6 +18,14 @@ describe("terminal image path detection", () => { ]) }) + it("detects the local image path from issue 250 output", () => { + expect(detectTerminalImagePaths(issue250DeleteCommand)).toEqual([issue250ImagePath]) + }) + + it("detects file urls for absolute local image paths", () => { + expect(detectTerminalImagePaths(`open ${issue250FileUrl}`)).toEqual([issue250FileUrl]) + }) + it("returns match ranges for clickable image paths", () => { expect(detectTerminalImagePathMatches("see /var/data/a.png.")).toEqual([ { diff --git a/packages/app/tests/docker-git/terminal-inline-images-core.test.ts b/packages/app/tests/docker-git/terminal-inline-images-core.test.ts index 9dadbf1e..cc6a3365 100644 --- a/packages/app/tests/docker-git/terminal-inline-images-core.test.ts +++ b/packages/app/tests/docker-git/terminal-inline-images-core.test.ts @@ -1,7 +1,19 @@ import { describe, expect, it } from "@effect/vitest" +import { vi } from "vitest" import { splitTerminalInlineImageOutput } from "../../src/web/terminal-inline-images-core.js" -import { terminalInlineImagePreviewRows, terminalInlineImageSpacer } from "../../src/web/terminal-inline-images.js" +import { + cachedTerminalInlineImageEntry, + cacheTerminalInlineImageBlob, + revokeTerminalInlineImageObjectUrlCache, + terminalInlineImagePreviewRows, + terminalInlineImageSpacer, + unavailableTerminalInlineImageEntry +} from "../../src/web/terminal-inline-images.js" + +const issue250ImagePath = `/${["t", "mp"].join("")}/phantom-e2e.tuhl98/wallet-step-after-password.png` +const issue250DeleteCommand = `Ran rm -f ${issue250ImagePath}` +const issue250FileUrl = `file://${issue250ImagePath}` describe("terminal inline image output", () => { it("keeps prompt output after a completed image path line in a later segment", () => { @@ -29,8 +41,60 @@ describe("terminal inline image output", () => { ]) }) + it("captures image paths from deletion command output", () => { + expect(splitTerminalInlineImageOutput(issue250DeleteCommand)).toEqual([ + { + endedWithLineBreak: false, + imagePaths: [issue250ImagePath], + text: issue250DeleteCommand + } + ]) + }) + + it("captures file url image paths", () => { + expect(splitTerminalInlineImageOutput(`saved ${issue250FileUrl}\r\n`)).toEqual([ + { + endedWithLineBreak: true, + imagePaths: [issue250FileUrl], + text: `saved ${issue250FileUrl}\r\n` + } + ]) + }) + it("keeps inline image previews compact in the terminal output stream", () => { expect(terminalInlineImagePreviewRows).toBe(4) expect(terminalInlineImageSpacer).toBe("\r\n\r\n\r\n\r\n") }) + + it("caches successful image fetch blobs as reusable object urls", () => { + const createObjectUrl = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:terminal-image") + const revokeObjectUrl = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => void 0) + const cache = new Map() + const blob = new Blob(["image"], { type: "image/png" }) + const imagePath = "/var/data/example.png" + const expectedEntry = { + _tag: "AvailableTerminalInlineImage", + displayUrl: "blob:terminal-image", + fetchUrl: "https://api/image", + path: imagePath + } + + expect(cacheTerminalInlineImageBlob(cache, imagePath, "https://api/image", blob)).toEqual(expectedEntry) + expect(cachedTerminalInlineImageEntry(cache, imagePath, "https://api/image")).toEqual(expectedEntry) + expect(cacheTerminalInlineImageBlob(cache, imagePath, "https://api/image", blob)).toEqual(expectedEntry) + expect(createObjectUrl).toHaveBeenCalledTimes(1) + + revokeTerminalInlineImageObjectUrlCache(cache) + + expect(revokeObjectUrl).toHaveBeenCalledWith("blob:terminal-image") + expect(cache.size).toBe(0) + }) + + it("represents failed image fetches without using a broken image url", () => { + expect(unavailableTerminalInlineImageEntry("/var/data/missing.png", "https://api/image")).toEqual({ + _tag: "UnavailableTerminalInlineImage", + fetchUrl: "https://api/image", + path: "/var/data/missing.png" + }) + }) }) diff --git a/packages/app/tsconfig.json b/packages/app/tsconfig.json index 58c0fd24..ac6e0290 100644 --- a/packages/app/tsconfig.json +++ b/packages/app/tsconfig.json @@ -7,11 +7,10 @@ "types": ["vitest", "vite/client"], "lib": ["ES2023", "DOM", "DOM.Iterable"], "jsx": "react-jsx", - "baseUrl": ".", "paths": { - "@/*": ["src/*"], - "@lib": ["src/lib/index.ts"], - "@lib/*": ["src/lib/*.ts"] + "@/*": ["./src/*"], + "@lib": ["./src/lib/index.ts"], + "@lib/*": ["./src/lib/*.ts"] } }, "include": [ diff --git a/packages/docker-git-session-sync/tsconfig.json b/packages/docker-git-session-sync/tsconfig.json index eb355b16..8e8358c6 100644 --- a/packages/docker-git-session-sync/tsconfig.json +++ b/packages/docker-git-session-sync/tsconfig.json @@ -3,8 +3,7 @@ "compilerOptions": { "rootDir": ".", "outDir": "dist", - "types": ["vitest", "node"], - "baseUrl": "." + "types": ["vitest", "node"] }, "include": ["src/**/*", "tests/**/*", "vite.config.ts"], "exclude": ["dist", "node_modules"] diff --git a/packages/lib/src/core/templates-zsh.ts b/packages/lib/src/core/templates-zsh.ts index ceefa95e..635a787d 100644 --- a/packages/lib/src/core/templates-zsh.ts +++ b/packages/lib/src/core/templates-zsh.ts @@ -84,9 +84,9 @@ docker_git_prompt_apply() { docker_git_terminal_sanitize local b b="$(docker_git_branch)" - local short_pwd - short_pwd="$(docker_git_short_pwd)" - local base="[%*] $short_pwd" + local short_path + short_path="$(docker_git_short_pwd)" + local base="[%*] $short_path" if [[ -n "$b" ]]; then PROMPT="$base ($b)> " else diff --git a/packages/lib/tsconfig.json b/packages/lib/tsconfig.json index 028caa14..788d2ea0 100644 --- a/packages/lib/tsconfig.json +++ b/packages/lib/tsconfig.json @@ -6,9 +6,8 @@ "declaration": true, "declarationMap": true, "types": ["vitest", "node"], - "baseUrl": ".", "paths": { - "@/*": ["src/*"] + "@/*": ["./src/*"] } }, "include": ["src/**/*"],