diff --git a/server/web-server.ts b/server/web-server.ts
index 414c77b..4291223 100644
--- a/server/web-server.ts
+++ b/server/web-server.ts
@@ -1,6 +1,6 @@
import { EventEmitter } from "node:events";
import { randomUUID } from "node:crypto";
-import { existsSync } from "node:fs";
+import { existsSync, mkdirSync, realpathSync } from "node:fs";
import { mkdir, readFile, readdir, realpath, stat, writeFile } from "node:fs/promises";
import { homedir, tmpdir } from "node:os";
import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
@@ -416,12 +416,28 @@ const servesFrontend = !["api", "api-only", "backend", "backend-only"].includes(
const authToken = process.env.OPENGUI_AUTH_TOKEN?.trim() || "";
const allowedCorsOrigin = process.env.OPENGUI_CORS_ORIGIN?.trim() || "*";
+const uploadDirectory = join(tmpdir(), "opengui-uploads");
+mkdirSync(uploadDirectory, { recursive: true });
+
+function addCanonicalRootVariants(root: string) {
+ const resolved = resolve(root);
+ try {
+ const real = realpathSync(resolved);
+ return real === resolved ? [resolved] : [resolved, real];
+ } catch {
+ return [resolved];
+ }
+}
+
function parseAllowedRoots() {
const raw = process.env.OPENGUI_ALLOWED_ROOTS || homedir();
- return raw
- .split(",")
- .map((entry) => resolve(entry.trim()))
- .filter(Boolean);
+ return Array.from(
+ new Set(
+ [...raw.split(",").map((entry) => entry.trim()), uploadDirectory]
+ .filter(Boolean)
+ .flatMap(addCanonicalRootVariants),
+ ),
+ );
}
const allowedRoots = parseAllowedRoots();
@@ -1575,8 +1591,7 @@ app.get("/api/fs/file", async (c) => {
? inputPath
: join(await resolveSafeDirectory(directory), inputPath);
const actual = await realpath(requestedPath);
- const allowed = allowedRoots.some((root) => actual === root || actual.startsWith(`${root}/`));
- if (!allowed) throw new Error("Path outside OPENGUI_ALLOWED_ROOTS");
+ if (!isWithinAllowedRoot(actual)) throw new Error("Path outside OPENGUI_ALLOWED_ROOTS");
const info = await stat(actual);
if (!info.isFile()) throw new Error("Path is not a file");
return new Response(await readFile(actual), {
@@ -1615,8 +1630,7 @@ app.post("/api/fs/upload", async (c) => {
if (file.size > uploadMaxFileBytes) throw new Error("File exceeds size limit");
}
- const dir = join(tmpdir(), "opengui-uploads");
- await mkdir(dir, { recursive: true });
+ await mkdir(uploadDirectory, { recursive: true });
const uploaded: string[] = [];
for (const file of files) {
@@ -1624,7 +1638,7 @@ app.post("/api/fs/upload", async (c) => {
const extension = extname(originalName)
.replace(/[^a-zA-Z0-9.]/g, "")
.slice(0, 24);
- const filePath = join(dir, `${randomUUID()}${extension}`);
+ const filePath = join(uploadDirectory, `${randomUUID()}${extension}`);
await writeFile(filePath, Buffer.from(await file.arrayBuffer()));
uploaded.push(filePath);
}
diff --git a/src/components/ImageMentionPreview.tsx b/src/components/ImageMentionPreview.tsx
index 3176bdb..e7611bd 100644
--- a/src/components/ImageMentionPreview.tsx
+++ b/src/components/ImageMentionPreview.tsx
@@ -13,11 +13,13 @@ function ImageLightbox({
image,
serverUrl,
baseDirectory,
+ authToken,
onClose,
}: {
image: ImageMention;
serverUrl?: string | null;
baseDirectory?: string | null;
+ authToken?: string | null;
onClose: () => void;
}) {
React.useEffect(() => {
@@ -28,7 +30,7 @@ function ImageLightbox({
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
- const src = resolveAttachmentImageSrc(image.path, serverUrl, baseDirectory);
+ const src = resolveAttachmentImageSrc(image.path, serverUrl, baseDirectory, authToken);
return createPortal(
void;
onOpen?: (image: ImageMention) => void;
@@ -99,6 +103,7 @@ export function ImageMentionToken({
image={localOpenImage}
serverUrl={serverUrl}
baseDirectory={baseDirectory}
+ authToken={authToken}
onClose={() => setLocalOpenImage(null)}
/>
)}
@@ -110,6 +115,7 @@ export function ImageMentionThumbnails({
images,
serverUrl,
baseDirectory,
+ authToken,
activePath,
onHover,
onOpen,
@@ -118,6 +124,7 @@ export function ImageMentionThumbnails({
images: ImageMention[];
serverUrl?: string | null;
baseDirectory?: string | null;
+ authToken?: string | null;
activePath?: string | null;
onHover?: (path: string | null) => void;
onOpen?: (image: ImageMention) => void;
@@ -130,7 +137,7 @@ export function ImageMentionThumbnails({
<>
{images.map((image) => {
- const src = resolveAttachmentImageSrc(image.path, serverUrl, baseDirectory);
+ const src = resolveAttachmentImageSrc(image.path, serverUrl, baseDirectory, authToken);
const active = activePath === image.path;
return (
@@ -178,6 +193,7 @@ export const MessageBubble = memo(function MessageBubble({
image={openImage}
serverUrl={imageServerUrl}
baseDirectory={imageBaseDirectory}
+ authToken={imageAuthToken}
onClose={() => setOpenImage(null)}
/>
{info.role === "assistant" && turnFooter && (
diff --git a/src/components/message-list/PartView.tsx b/src/components/message-list/PartView.tsx
index 848ddf5..1c1c23e 100644
--- a/src/components/message-list/PartView.tsx
+++ b/src/components/message-list/PartView.tsx
@@ -16,6 +16,8 @@ export const PartView = memo(function PartView({
onImageHover,
onImageOpen,
imageBaseDirectory,
+ imageServerUrl,
+ imageAuthToken,
}: {
part: Part;
isUser?: boolean;
@@ -26,6 +28,8 @@ export const PartView = memo(function PartView({
onImageHover?: (path: string | null) => void;
onImageOpen?: (image: ImageMention) => void;
imageBaseDirectory?: string | null;
+ imageServerUrl?: string | null;
+ imageAuthToken?: string | null;
}) {
switch (part.type) {
case "text":
@@ -37,10 +41,14 @@ export const PartView = memo(function PartView({
onImageHover={onImageHover}
onImageOpen={onImageOpen}
imageBaseDirectory={imageBaseDirectory}
+ imageServerUrl={imageServerUrl}
+ imageAuthToken={imageAuthToken}
/>
);
case "file":
- return
;
+ return (
+
+ );
case "reasoning":
return
;
case "tool":
@@ -49,6 +57,8 @@ export const PartView = memo(function PartView({
part={part}
expandedToolParts={expandedToolParts}
onToggleToolPart={onToggleToolPart}
+ imageServerUrl={imageServerUrl}
+ imageAuthToken={imageAuthToken}
/>
);
case "step-start":
diff --git a/src/components/message-list/TextPartView.tsx b/src/components/message-list/TextPartView.tsx
index 2cefd16..bfccd31 100644
--- a/src/components/message-list/TextPartView.tsx
+++ b/src/components/message-list/TextPartView.tsx
@@ -1,7 +1,6 @@
import type { TextPart } from "@opencode-ai/sdk/v2/client";
import { ImageMentionToken, type ImageMention } from "@/components/ImageMentionPreview";
import { MarkdownRenderer } from "@/components/MarkdownRenderer";
-import { useConnectionState } from "@/hooks/use-agent-state";
import { splitImageMentions } from "@/lib/image-mentions";
export function TextPartView({
@@ -11,6 +10,8 @@ export function TextPartView({
onImageHover,
onImageOpen,
imageBaseDirectory,
+ imageServerUrl,
+ imageAuthToken,
}: {
part: TextPart;
isUser?: boolean;
@@ -18,8 +19,9 @@ export function TextPartView({
onImageHover?: (path: string | null) => void;
onImageOpen?: (image: ImageMention) => void;
imageBaseDirectory?: string | null;
+ imageServerUrl?: string | null;
+ imageAuthToken?: string | null;
}) {
- const { isLocalWorkspace, workspaceServerUrl } = useConnectionState();
if (!part.text) return null;
if (isUser) {
@@ -34,8 +36,9 @@ export function TextPartView({
key={`${segment.path}-${index}`}
token={segment.token}
image={{ path: segment.path, filename: segment.filename }}
- serverUrl={isLocalWorkspace ? null : workspaceServerUrl}
+ serverUrl={imageServerUrl}
baseDirectory={imageBaseDirectory}
+ authToken={imageAuthToken}
active={activeImagePath === segment.path}
onHover={onImageHover}
onOpen={onImageOpen}
diff --git a/src/components/message-list/tools/ToolPartView.tsx b/src/components/message-list/tools/ToolPartView.tsx
index f1e2eea..555bb12 100644
--- a/src/components/message-list/tools/ToolPartView.tsx
+++ b/src/components/message-list/tools/ToolPartView.tsx
@@ -21,7 +21,6 @@ import { useEffect, useRef } from "react";
import { MarkdownRenderer } from "@/components/MarkdownRenderer";
import { TerminalOutput } from "@/components/message-list/TerminalOutput";
import { Spinner } from "@/components/ui/spinner";
-import { useConnectionState } from "@/hooks/use-agent-state";
import { todoStatusConfig, type TodoItem } from "@/lib/todos";
import { cn, looksLikeTerminalOutput } from "@/lib/utils";
import { ApplyPatchFilesView } from "./ApplyPatchFilesView";
@@ -273,13 +272,16 @@ export function ToolPartView({
part,
expandedToolParts,
onToggleToolPart,
+ imageServerUrl,
+ imageAuthToken,
}: {
part: ToolPart;
expandedToolParts?: ReadonlySet
;
onToggleToolPart?: (partId: string, expanded: boolean) => void;
+ imageServerUrl?: string | null;
+ imageAuthToken?: string | null;
}) {
- const { workspaceServerUrl } = useConnectionState();
- const presentation = getToolPresentation(part, workspaceServerUrl);
+ const presentation = getToolPresentation(part, imageServerUrl, imageAuthToken);
const expanded = expandedToolParts?.has(part.id) ?? false;
const setExpanded = (nextExpanded: boolean) => onToggleToolPart?.(part.id, nextExpanded);
const autoExpandedRef = useRef(false);
diff --git a/src/components/message-list/tools/imageAttachments.ts b/src/components/message-list/tools/imageAttachments.ts
index 146568b..9eb476b 100644
--- a/src/components/message-list/tools/imageAttachments.ts
+++ b/src/components/message-list/tools/imageAttachments.ts
@@ -11,6 +11,7 @@ export interface ImageAttachmentInfo {
export function extractImageAttachments(
state: ToolPart["state"],
serverUrl?: string | null,
+ authToken?: string | null,
): ImageAttachmentInfo[] {
if (state.status !== "completed") return [];
if (!Array.isArray(state.attachments) || state.attachments.length === 0) return [];
@@ -22,7 +23,7 @@ export function extractImageAttachments(
})
.map((att) => ({
url: att.url,
- src: resolveAttachmentImageSrc(att.url, serverUrl),
+ src: resolveAttachmentImageSrc(att.url, serverUrl, null, authToken),
mime: att.mime,
filename: att.filename,
}));
diff --git a/src/components/message-list/tools/toolPresentation.ts b/src/components/message-list/tools/toolPresentation.ts
index d184838..22d8b4e 100644
--- a/src/components/message-list/tools/toolPresentation.ts
+++ b/src/components/message-list/tools/toolPresentation.ts
@@ -112,6 +112,7 @@ function getToolTitle(part: ToolPart, kind: ToolKind, isRunning: boolean): strin
export function getToolPresentation(
part: ToolPart,
workspaceServerUrl?: string | null,
+ workspaceAuthToken?: string | null,
): ToolPresentation {
const state = part.state;
const kind = normalizeToolKind(part.tool);
@@ -143,7 +144,7 @@ export function getToolPresentation(
const todos = kind === "todo" ? extractTodos(state) : null;
const taskInfo = kind === "task" ? extractTaskInfo(state) : null;
const taskDurationLabel = kind === "task" ? getTaskDurationLabel(state) : null;
- const images = extractImageAttachments(state, workspaceServerUrl);
+ const images = extractImageAttachments(state, workspaceServerUrl, workspaceAuthToken);
const command = kind === "bash" ? stringField(input, "command") : null;
const globPattern = kind === "glob" ? stringField(input, "pattern") : null;
diff --git a/src/hooks/use-prompt-files.ts b/src/hooks/use-prompt-files.ts
index 4e8538e..74c6a9e 100644
--- a/src/hooks/use-prompt-files.ts
+++ b/src/hooks/use-prompt-files.ts
@@ -26,12 +26,14 @@ export function usePromptFiles({
value,
setValue,
serverUrl,
+ authToken,
textareaRef,
}: {
disabled: boolean;
value: string;
setValue: React.Dispatch>;
serverUrl?: string | null;
+ authToken?: string | null;
textareaRef: React.RefObject;
}) {
const [isDragging, setIsDragging] = React.useState(false);
@@ -60,9 +62,10 @@ export function usePromptFiles({
const headers = new Headers();
const token =
- serverUrl?.trim() || !window.electronAPI?.backendToken
+ authToken?.trim() ||
+ (serverUrl?.trim() || !window.electronAPI?.backendToken
? getShellWorkspacePolicy().configuredWebWorkspace?.authToken
- : window.electronAPI.backendToken;
+ : window.electronAPI.backendToken);
if (token) headers.set("authorization", `Bearer ${token}`);
const base =
@@ -102,7 +105,7 @@ export function usePromptFiles({
setUploadProgress(100);
setIsUploading(false);
},
- [appendUploadedPaths, serverUrl],
+ [appendUploadedPaths, authToken, serverUrl],
);
const appendFiles = React.useCallback(
diff --git a/src/lib/__tests__/attachment-src.test.ts b/src/lib/__tests__/attachment-src.test.ts
index 553b906..2ee3625 100644
--- a/src/lib/__tests__/attachment-src.test.ts
+++ b/src/lib/__tests__/attachment-src.test.ts
@@ -13,6 +13,12 @@ describe("resolveAttachmentImageSrc", () => {
);
});
+ test("adds auth token to backend image URLs", () => {
+ expect(
+ resolveAttachmentImageSrc("/tmp/image.png", "http://localhost:4096/", null, "secret"),
+ ).toBe("http://localhost:4096/api/fs/file?path=%2Ftmp%2Fimage.png&token=secret");
+ });
+
test("resolves relative paths against the base directory", () => {
expect(resolveAttachmentImageSrc("screenshot.png", null, "/repo/project")).toBe(
"file:///repo/project/screenshot.png",
diff --git a/src/lib/attachment-src.ts b/src/lib/attachment-src.ts
index b115905..c478a6a 100644
--- a/src/lib/attachment-src.ts
+++ b/src/lib/attachment-src.ts
@@ -16,6 +16,7 @@ export function resolveAttachmentImageSrc(
url: string,
serverUrl?: string | null,
baseDirectory?: string | null,
+ authToken?: string | null,
): string {
const trimmed = url.trim();
if (!trimmed) return trimmed;
@@ -29,6 +30,7 @@ export function resolveAttachmentImageSrc(
if (normalizedServerUrl) {
const params = new URLSearchParams({ path: resolvedPath });
if (!isAbsolutePath(trimmed) && baseDirectory) params.set("directory", baseDirectory);
+ if (authToken?.trim()) params.set("token", authToken.trim());
return `${normalizedServerUrl}/api/fs/file?${params.toString()}`;
}