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
23 changes: 23 additions & 0 deletions apps/server/src/imageMime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,29 @@ export const IMAGE_MIME_TYPE_BY_EXTENSION: Record<string, string> = {
".webp": "image/webp",
};

/**
* The mime type a byte prefix actually is, for the raster formats a browser
* renders inline, or null when the bytes match no known format.
*
* Extensions lie; this reads the file's own signature. SVG is deliberately
* absent: it is text, so no signature can tell a drawing from a renamed script.
*/
export function detectRasterImageMimeType(bytes: Uint8Array): string | null {
const startsWith = (...signature: ReadonlyArray<number>) =>
signature.length <= bytes.length && signature.every((byte, index) => bytes[index] === byte);
const asciiAt = (offset: number, text: string) =>
offset + text.length <= bytes.length &&
[...text].every((char, index) => bytes[offset + index] === char.charCodeAt(0));

if (startsWith(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return "image/png";
if (startsWith(0xff, 0xd8, 0xff)) return "image/jpeg";
if (asciiAt(0, "GIF87a") || asciiAt(0, "GIF89a")) return "image/gif";
if (asciiAt(0, "RIFF") && asciiAt(8, "WEBP")) return "image/webp";
if (asciiAt(0, "BM")) return "image/bmp";
if (asciiAt(4, "ftyp") && (asciiAt(8, "avif") || asciiAt(8, "avis"))) return "image/avif";
return null;
}

export function parseBase64DataUrl(
dataUrl: string,
): { readonly mimeType: string; readonly base64: string } | null {
Expand Down
73 changes: 73 additions & 0 deletions apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,79 @@ it.layer(TestLayer)("WorkspaceFileSystemLive", (it) => {
}),
);

it.effect("serves an image outside the workspace root when its bytes are one", () =>
Effect.gen(function* () {
const workspaceFileSystem = yield* WorkspaceFileSystem;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const outside = yield* makeTempDir;
const cwd = yield* makeTempDir;
const bytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, 1, 2, 3]);
yield* fileSystem.writeFile(path.join(outside, "shot.png"), bytes).pipe(Effect.orDie);
const relativePath = path
.relative(cwd, path.join(outside, "shot.png"))
.replaceAll("\\", "/");

const result = yield* workspaceFileSystem.readFile({ cwd, relativePath });

expect(result).toEqual({
kind: "image",
relativePath,
mimeType: "image/png",
base64: Buffer.from(bytes).toString("base64"),
size: bytes.length,
});
}),
);

it.effect("still rejects text files outside the workspace root", () =>
Effect.gen(function* () {
const workspaceFileSystem = yield* WorkspaceFileSystem;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const outside = yield* makeTempDir;
const cwd = yield* makeTempDir;
yield* fileSystem
.writeFileString(path.join(outside, "secret.txt"), "secret")
.pipe(Effect.orDie);

const error = yield* workspaceFileSystem
.readFile({
cwd,
relativePath: path
.relative(cwd, path.join(outside, "secret.txt"))
.replaceAll("\\", "/"),
})
.pipe(Effect.flip);

expect(error._tag).toBe("WorkspacePathOutsideRootError");
}),
);

it.effect("rejects an outside-root .png whose bytes are not an image", () =>
Effect.gen(function* () {
const workspaceFileSystem = yield* WorkspaceFileSystem;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const outside = yield* makeTempDir;
const cwd = yield* makeTempDir;
yield* fileSystem
.writeFileString(path.join(outside, "not-really.png"), "#!/bin/sh\nrm -rf /\n")
.pipe(Effect.orDie);

const error = yield* workspaceFileSystem
.readFile({
cwd,
relativePath: path
.relative(cwd, path.join(outside, "not-really.png"))
.replaceAll("\\", "/"),
})
.pipe(Effect.flip);

expect(error._tag).toBe("WorkspacePathOutsideRootError");
}),
);

it.effect("rejects symlinks that escape the workspace root", () =>
Effect.gen(function* () {
const workspaceFileSystem = yield* WorkspaceFileSystem;
Expand Down
75 changes: 67 additions & 8 deletions apps/server/src/workspace/Layers/WorkspaceFileSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import * as Path from "effect/Path";

import type { ProjectReadFileResult, ProjectWriteFileResult } from "@threadlines/contracts";

import { IMAGE_MIME_TYPE_BY_EXTENSION, SAFE_IMAGE_FILE_EXTENSIONS } from "../../imageMime.ts";
import {
detectRasterImageMimeType,
IMAGE_MIME_TYPE_BY_EXTENSION,
SAFE_IMAGE_FILE_EXTENSIONS,
} from "../../imageMime.ts";
import {
WorkspaceFileSystem,
WorkspaceFileSystemError,
Expand Down Expand Up @@ -127,13 +131,71 @@ export const makeWorkspaceFileSystem = Effect.gen(function* () {
}),
);

const readFile: WorkspaceFileSystemShape["readFile"] = Effect.fn("WorkspaceFileSystem.readFile")(
function* (input) {
const target = yield* workspacePaths.resolveRelativePathWithinRoot({
/**
* Serves a target that resolved outside the workspace root, but only when its
* bytes really are a raster image.
*
* Agents save screenshots wherever the OS puts temp files, so a chat
* reference to one is ordinary and refusing it is exactly what leaves the
* picture blank. A client authenticated to this server already drives an
* agent with shell access on this machine, so handing it verified image bytes
* grants no capability it lacked. Everything else -- text, SVG (text too, so
* a renamed script sniffs as nothing), a `.png` that is not one, a missing
* path -- stays refused exactly as before.
*/
const readImageOutsideRoot = Effect.fn("WorkspaceFileSystem.readImageOutsideRoot")(function* (
input: { readonly cwd: string; readonly relativePath: string },
absolutePath: string,
) {
const rejectOutsideRoot = () =>
new WorkspacePathOutsideRootError({
workspaceRoot: input.cwd,
relativePath: input.relativePath,
});

const targetStat = yield* fileSystem
.stat(absolutePath)
.pipe(Effect.catch(() => Effect.succeed(null)));
if (
targetStat === null ||
targetStat.type !== "File" ||
Number(targetStat.size) > WORKSPACE_IMAGE_READ_MAX_BYTES
) {
return yield* rejectOutsideRoot();
}

const bytes = yield* fileSystem
.readFile(absolutePath)
.pipe(Effect.catch(() => Effect.succeed(null)));
const mimeType = bytes === null ? null : detectRasterImageMimeType(bytes);
if (bytes === null || mimeType === null) {
return yield* rejectOutsideRoot();
}

return {
kind: "image",
relativePath: input.relativePath,
mimeType,
base64: Buffer.from(bytes).toString("base64"),
size: bytes.length,
} satisfies ProjectReadFileResult;
});

const readFile: WorkspaceFileSystemShape["readFile"] = Effect.fn("WorkspaceFileSystem.readFile")(
function* (input) {
const target = yield* workspacePaths
.resolveRelativePathWithinRoot({
workspaceRoot: input.cwd,
relativePath: input.relativePath,
})
.pipe(Effect.catch(() => Effect.succeed(null)));
if (target === null) {
return yield* readImageOutsideRoot(
input,
path.resolve(input.cwd, input.relativePath.trim()),
);
}

// Lexical checks above cannot see symlinks; compare real paths so reads
// never follow a link out of the workspace root.
const rootRealPath = yield* fileSystem
Expand Down Expand Up @@ -161,10 +223,7 @@ export const makeWorkspaceFileSystem = Effect.gen(function* () {
realRelativePath.startsWith(`..${path.sep}`) ||
path.isAbsolute(realRelativePath)
) {
return yield* new WorkspacePathOutsideRootError({
workspaceRoot: input.cwd,
relativePath: input.relativePath,
});
return yield* readImageOutsideRoot(input, targetRealPath);
}

// Same NotFound handling as above: the file can vanish between the
Expand Down
87 changes: 87 additions & 0 deletions apps/web/src/components/ChatMarkdown.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import "../index.css";

import { scopeThreadRef } from "@threadlines/client-runtime";
import { EnvironmentId, type EnvironmentApi, ThreadId } from "@threadlines/contracts";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactElement, ReactNode } from "react";
import { page } from "vite-plus/test/browser";
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
import { render } from "vitest-browser-react";
Expand Down Expand Up @@ -63,6 +65,26 @@ const CHAT_MARKDOWN_THREAD_REF = scopeThreadRef(
CHAT_MARKDOWN_THREAD_ID,
);

// The inline image loader reads files through react-query, so those renders
// need the provider the app root supplies.
function renderWithQueryClient(ui: ReactElement) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
return render(ui, {
wrapper: ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
),
});
}

function installReadFileEnvironment(readFile: EnvironmentApi["projects"]["readFile"]) {
__setEnvironmentApiOverrideForTests(CHAT_MARKDOWN_ENVIRONMENT_ID, {
filesystem: { browse: filesystemBrowseMock },
projects: { readFile },
} as unknown as EnvironmentApi);
}

function installFilesystemBrowseEnvironment() {
__setEnvironmentApiOverrideForTests(CHAT_MARKDOWN_ENVIRONMENT_ID, {
filesystem: { browse: filesystemBrowseMock },
Expand Down Expand Up @@ -637,6 +659,71 @@ describe("ChatMarkdown", () => {
}
});

it("shows a picture for an image path an agent wrote in prose", async () => {
const pixelBase64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
const readFile = vi.fn(async (_input: { cwd: string; relativePath: string }) => ({
kind: "image" as const,
relativePath: "../Temp/shot.png",
mimeType: "image/png",
base64: pixelBase64,
size: 70,
}));
installReadFileEnvironment(readFile as unknown as EnvironmentApi["projects"]["readFile"]);

const screen = await renderWithQueryClient(
<ChatMarkdown
text="Saved the screenshot to /tmp/Temp/shot.png for review."
cwd="/tmp/project"
environmentId={CHAT_MARKDOWN_ENVIRONMENT_ID}
/>,
);

try {
const thumbnail = page.getByRole("img", { name: "shot.png" });
await expect.element(thumbnail).toBeInTheDocument();
await expect
.element(thumbnail)
.toHaveAttribute("src", `data:image/png;base64,${pixelBase64}`);
// The chip stays under the picture and still opens the file.
await expect.element(page.getByRole("link", { name: "shot.png" })).toBeInTheDocument();
expect(readFile.mock.calls[0]?.[0]).toEqual({
cwd: "/tmp/project",
relativePath: "../Temp/shot.png",
});
} finally {
await screen.unmount();
}
});

it("shows only the chip when a referenced image is gone", async () => {
const readFile = vi.fn(async () => ({
kind: "missing" as const,
relativePath: "shots/gone.png",
}));
installReadFileEnvironment(readFile as unknown as EnvironmentApi["projects"]["readFile"]);

const screen = await renderWithQueryClient(
<ChatMarkdown
text="![before](shots/gone.png)"
cwd="/repo/project"
environmentId={CHAT_MARKDOWN_ENVIRONMENT_ID}
/>,
);

try {
await expect.element(page.getByRole("link", { name: "before" })).toBeInTheDocument();
await vi.waitFor(() => {
expect(readFile).toHaveBeenCalled();
});
// Only the chip's file-type glyph; no thumbnail and no error box.
expect(document.querySelector('img[alt="before"]')).toBeNull();
expect(document.querySelector('button[aria-label="Preview before"]')).toBeNull();
} finally {
await screen.unmount();
}
});

it("wraps long fenced text and shows copy feedback", async () => {
const code = `Please run ${"a-very-long-unbroken-value".repeat(20)} when ready.`;
const writeText = vi.fn(async () => undefined);
Expand Down
Loading
Loading