feat: add workspace management features
- Implemented a new WorkspacePanel component for browsing and managing workspace files and directories. - Added WorkspaceFileDialog for viewing and downloading files from the workspace. - Created API routes for downloading the entire workspace as a zip file and for accessing individual workspace files. - Introduced server-side utilities for workspace management, including path resolution and access control. - Enhanced ChatInterface to include a workspace tab for easy access to workspace features.
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
import { createReadStream, promises as fs } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { randomUUID } from "crypto";
|
||||
import { spawn } from "child_process";
|
||||
import { Readable } from "stream";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
getWorkspaceDir,
|
||||
zipExcludeArgs,
|
||||
isCrossOrigin,
|
||||
} from "@/lib/server/workspace";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/** Zip `workspaceDir` (minus the ignore list) into `outFile` using the OS `zip`.
|
||||
* Aborts (and kills the child) if `signal` fires — e.g. the client disconnects
|
||||
* mid-archive. */
|
||||
function zipWorkspace(
|
||||
workspaceDir: string,
|
||||
outFile: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// -r recurse, -q quiet, -X drop extra file attributes, -y store symlinks AS
|
||||
// symlinks instead of dereferencing them (so a symlink pointing outside the
|
||||
// workspace can't pull external file *contents* into the archive).
|
||||
// Exclusions come from the shared ignore lists so the archive matches the
|
||||
// tree exactly (dotfiles, large_tool_results/conversation_history, build noise).
|
||||
const child = spawn(
|
||||
"zip",
|
||||
["-r", "-q", "-X", "-y", outFile, ".", ...zipExcludeArgs()],
|
||||
{ cwd: workspaceDir }
|
||||
);
|
||||
|
||||
const onAbort = () => child.kill("SIGKILL");
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
child.kill("SIGKILL");
|
||||
reject(new Error("Request aborted."));
|
||||
return;
|
||||
}
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
|
||||
let stderr = "";
|
||||
child.stderr.on("data", (d) => (stderr += d.toString()));
|
||||
child.on("error", (err) => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
reject(
|
||||
(err as NodeJS.ErrnoException).code === "ENOENT"
|
||||
? new Error(
|
||||
"The `zip` command is not available on this system, so the workspace can't be downloaded as an archive."
|
||||
)
|
||||
: err
|
||||
);
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
if (signal?.aborted) reject(new Error("Request aborted."));
|
||||
// 12 = "nothing to do" (empty workspace) — treat as a friendly error.
|
||||
else if (code === 12) reject(new Error("The workspace is empty."));
|
||||
else if (code !== 0)
|
||||
reject(new Error(stderr.trim() || `zip exited with code ${code}`));
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
let tmpFile: string | null = null;
|
||||
try {
|
||||
if (isCrossOrigin(request)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Cross-origin workspace access is not allowed." },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const workspaceDir = await getWorkspaceDir();
|
||||
tmpFile = join(tmpdir(), `evoscientist-workspace-${randomUUID()}.zip`);
|
||||
await zipWorkspace(workspaceDir, tmpFile, request.signal);
|
||||
|
||||
const stat = await fs.stat(tmpFile);
|
||||
const nodeStream = createReadStream(tmpFile);
|
||||
// Delete the temp archive once the response has been fully read (or the
|
||||
// client disconnects) — `close` fires in both cases.
|
||||
const cleanup = tmpFile;
|
||||
nodeStream.on("close", () => void fs.rm(cleanup, { force: true }));
|
||||
const webStream = Readable.toWeb(nodeStream) as ReadableStream<Uint8Array>;
|
||||
|
||||
return new NextResponse(webStream, {
|
||||
headers: {
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Length": String(stat.size),
|
||||
"Content-Disposition": 'attachment; filename="workspace.zip"',
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (tmpFile) await fs.rm(tmpFile, { force: true }).catch(() => {});
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to package the workspace.",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { createReadStream } from "fs";
|
||||
import { promises as fs } from "fs";
|
||||
import { basename, extname } from "path";
|
||||
import { Readable } from "stream";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
getWorkspaceDir,
|
||||
safeResolve,
|
||||
isCrossOrigin,
|
||||
} from "@/lib/server/workspace";
|
||||
|
||||
/** RFC 6266 Content-Disposition value with both an ASCII fallback and a UTF-8
|
||||
* `filename*` so non-ASCII names (e.g. Chinese) download with their real name
|
||||
* instead of percent-encoded gibberish. */
|
||||
function contentDisposition(fileName: string, asAttachment: boolean): string {
|
||||
const ascii = fileName.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_");
|
||||
const encoded = encodeURIComponent(fileName).replace(
|
||||
/['()*]/g,
|
||||
(c) => "%" + c.charCodeAt(0).toString(16).toUpperCase()
|
||||
);
|
||||
return `${asAttachment ? "attachment" : "inline"}; filename="${ascii}"; filename*=UTF-8''${encoded}`;
|
||||
}
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
// Map common research-output extensions to a Content-Type. Anything unlisted is
|
||||
// served as a download (octet-stream) so the browser never tries to execute it.
|
||||
const CONTENT_TYPES: Record<string, string> = {
|
||||
txt: "text/plain; charset=utf-8",
|
||||
md: "text/markdown; charset=utf-8",
|
||||
log: "text/plain; charset=utf-8",
|
||||
csv: "text/csv; charset=utf-8",
|
||||
tsv: "text/tab-separated-values; charset=utf-8",
|
||||
json: "application/json; charset=utf-8",
|
||||
py: "text/plain; charset=utf-8",
|
||||
js: "text/plain; charset=utf-8",
|
||||
ts: "text/plain; charset=utf-8",
|
||||
tsx: "text/plain; charset=utf-8",
|
||||
sh: "text/plain; charset=utf-8",
|
||||
yaml: "text/plain; charset=utf-8",
|
||||
yml: "text/plain; charset=utf-8",
|
||||
toml: "text/plain; charset=utf-8",
|
||||
tex: "text/plain; charset=utf-8",
|
||||
bib: "text/plain; charset=utf-8",
|
||||
html: "text/plain; charset=utf-8", // never text/html — don't let it render
|
||||
css: "text/plain; charset=utf-8",
|
||||
xml: "text/plain; charset=utf-8",
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
svg: "image/svg+xml",
|
||||
bmp: "image/bmp",
|
||||
pdf: "application/pdf",
|
||||
};
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
if (isCrossOrigin(request)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Cross-origin workspace access is not allowed." },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const relPath = request.nextUrl.searchParams.get("path");
|
||||
if (!relPath) {
|
||||
return NextResponse.json({ error: "Missing path." }, { status: 400 });
|
||||
}
|
||||
const download = request.nextUrl.searchParams.get("download") === "1";
|
||||
|
||||
const workspaceDir = await getWorkspaceDir();
|
||||
// safeResolve canonicalizes + re-checks containment, so a symlink can't be
|
||||
// used to read a file outside the workspace (or a hidden/internal entry).
|
||||
const target = await safeResolve(workspaceDir, relPath);
|
||||
|
||||
const stat = await fs.stat(target);
|
||||
if (!stat.isFile()) {
|
||||
return NextResponse.json(
|
||||
{ error: "Not a file." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const ext = extname(target).slice(1).toLowerCase();
|
||||
const contentType = CONTENT_TYPES[ext] ?? "application/octet-stream";
|
||||
// Octet-stream and explicit ?download=1 go out as attachments; previewable
|
||||
// types render inline.
|
||||
const asAttachment = download || contentType === "application/octet-stream";
|
||||
|
||||
const nodeStream = createReadStream(target);
|
||||
const webStream = Readable.toWeb(nodeStream) as ReadableStream<Uint8Array>;
|
||||
|
||||
const fileName = basename(target);
|
||||
return new NextResponse(webStream, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Content-Length": String(stat.size),
|
||||
"Content-Disposition": contentDisposition(fileName, asAttachment),
|
||||
// Workspace files are agent/user-controlled. `sandbox` neutralizes
|
||||
// scripts in an inline SVG/HTML opened directly (XSS), and `nosniff`
|
||||
// stops the browser from sniffing a text/* file into executable HTML.
|
||||
// Neither affects <img>/<iframe> preview rendering in the UI.
|
||||
"Content-Security-Policy": "sandbox",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
// The path uniquely identifies a one-shot fetch; never cache stale agent output.
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
error instanceof Error ? error.message : "Failed to read file.",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { promises as fs } from "fs";
|
||||
import { extname } from "path";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
getWorkspaceDir,
|
||||
safeResolve,
|
||||
isHiddenEntry,
|
||||
isCrossOrigin,
|
||||
} from "@/lib/server/workspace";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
// Cap how many entries a single directory listing returns so a pathological
|
||||
// directory can't stall the UI or the response.
|
||||
const MAX_ENTRIES = 2000;
|
||||
|
||||
export interface WorkspaceEntry {
|
||||
name: string;
|
||||
/** Path relative to the workspace root, e.g. "artifacts/report.md". */
|
||||
path: string;
|
||||
type: "dir" | "file";
|
||||
size: number;
|
||||
/** Last-modified epoch ms. */
|
||||
mtime: number;
|
||||
/** Lowercase extension without the dot, "" for none/dirs. */
|
||||
ext: string;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
if (isCrossOrigin(request)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Cross-origin workspace access is not allowed." },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const relPath = request.nextUrl.searchParams.get("path") ?? "";
|
||||
const workspaceDir = await getWorkspaceDir();
|
||||
const dir = await safeResolve(workspaceDir, relPath);
|
||||
|
||||
const stat = await fs.stat(dir);
|
||||
if (!stat.isDirectory()) {
|
||||
return NextResponse.json(
|
||||
{ error: "Not a directory." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const dirents = await fs.readdir(dir, { withFileTypes: true });
|
||||
const entries: WorkspaceEntry[] = [];
|
||||
for (const dirent of dirents) {
|
||||
if (entries.length >= MAX_ENTRIES) break;
|
||||
// Skip noise (dotfiles, internal dirs, build artifacts) — not research
|
||||
// artifacts. See isHiddenEntry for the full policy.
|
||||
if (isHiddenEntry(dirent.name)) continue;
|
||||
|
||||
const childRel = relPath
|
||||
? `${relPath.replace(/\/+$/, "")}/${dirent.name}`
|
||||
: dirent.name;
|
||||
|
||||
// safeResolve canonicalizes and re-checks containment, so a symlink that
|
||||
// escapes the workspace (or points at a hidden entry) is skipped, not
|
||||
// listed. Also drops anything that won't stat (broken link, or the agent
|
||||
// deleting a file mid-listing).
|
||||
let size = 0;
|
||||
let mtime = 0;
|
||||
let entryIsDir = dirent.isDirectory();
|
||||
try {
|
||||
const realChild = await safeResolve(workspaceDir, childRel);
|
||||
const entryStat = await fs.stat(realChild);
|
||||
size = entryStat.size;
|
||||
mtime = entryStat.mtimeMs;
|
||||
entryIsDir = entryStat.isDirectory();
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.push({
|
||||
name: dirent.name,
|
||||
path: childRel,
|
||||
type: entryIsDir ? "dir" : "file",
|
||||
size,
|
||||
mtime,
|
||||
ext: entryIsDir ? "" : extname(dirent.name).slice(1).toLowerCase(),
|
||||
});
|
||||
}
|
||||
|
||||
// Directories first, then files, each alphabetical (case-insensitive).
|
||||
entries.sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
|
||||
return a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
|
||||
});
|
||||
|
||||
const parent =
|
||||
relPath && relPath !== "."
|
||||
? relPath.replace(/\/+$/, "").split("/").slice(0, -1).join("/")
|
||||
: null;
|
||||
|
||||
return NextResponse.json({ path: relPath, parent, entries });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to list workspace.",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,96 +1,14 @@
|
||||
import { promises as fs } from "fs";
|
||||
import { homedir } from "os";
|
||||
import { basename, dirname, join, resolve } from "path";
|
||||
import { basename, dirname, resolve } from "path";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getWorkspaceDir, hasControlChar } from "@/lib/server/workspace";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const WORKSPACE_SIDECAR = join(
|
||||
homedir(),
|
||||
".config",
|
||||
"evoscientist",
|
||||
"langgraph_dev.workspace.json"
|
||||
);
|
||||
const MAX_FILE_BYTES = 50 * 1024 * 1024;
|
||||
const MAX_TOTAL_BYTES = 100 * 1024 * 1024;
|
||||
const MAX_FILES = 20;
|
||||
|
||||
interface WorkspaceSidecar {
|
||||
workspace?: unknown;
|
||||
pid?: unknown;
|
||||
}
|
||||
|
||||
/** True if the name contains any C0 control char (< 0x20) or DEL (0x7f). A
|
||||
* newline in a filename would otherwise be spliced into the prompt sent to the
|
||||
* agent (instruction injection); control chars have no place in a filename. */
|
||||
function hasControlChar(name: string): boolean {
|
||||
for (let i = 0; i < name.length; i += 1) {
|
||||
const code = name.charCodeAt(i);
|
||||
if (
|
||||
code < 0x20 || // C0 controls (incl. NUL, tab, newline)
|
||||
code === 0x7f || // DEL
|
||||
(code >= 0x80 && code <= 0x9f) || // C1 controls
|
||||
code === 0x2028 || // line separator
|
||||
code === 0x2029 // paragraph separator
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** True if a process with `pid` is currently running (signal 0 = existence probe). */
|
||||
function isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
// EPERM means the process exists but we may not signal it — still alive.
|
||||
return (error as NodeJS.ErrnoException).code === "EPERM";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the workspace of the *currently running* EvoScientist deployment.
|
||||
*
|
||||
* The sidecar records `{ workspace, pid }` of the langgraph dev that owns this
|
||||
* workspace. We only trust it when that pid is still alive — a stale sidecar
|
||||
* from a crashed/previous session must not silently redirect uploads to a
|
||||
* directory the live deployment no longer uses. Falls back to the launcher env.
|
||||
*/
|
||||
async function getWorkspaceDir() {
|
||||
let workspace: string | undefined;
|
||||
try {
|
||||
const sidecar = JSON.parse(
|
||||
await fs.readFile(WORKSPACE_SIDECAR, "utf-8")
|
||||
) as WorkspaceSidecar;
|
||||
const ws = sidecar.workspace;
|
||||
const pid = sidecar.pid;
|
||||
if (typeof ws === "string" && ws.trim()) {
|
||||
const hasPid = typeof pid === "number" && pid > 0;
|
||||
// With a recorded backend pid, only trust the sidecar while that process
|
||||
// is alive; older sidecars without one fall back to trusting it as before.
|
||||
if (!hasPid || isProcessAlive(pid as number)) workspace = ws;
|
||||
}
|
||||
} catch {
|
||||
// Older/manual setups may not have a sidecar. Fall back to the launcher env.
|
||||
}
|
||||
|
||||
workspace ||= process.env.EVOSCIENTIST_WORKSPACE_DIR;
|
||||
if (!workspace) {
|
||||
throw new Error(
|
||||
"No active EvoScientist workspace found. Start the backend with `EvoSci deploy` first."
|
||||
);
|
||||
}
|
||||
|
||||
const workspaceDir = resolve(workspace);
|
||||
const stat = await fs.stat(workspaceDir);
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error("The active EvoScientist workspace is not a directory.");
|
||||
}
|
||||
return workspaceDir;
|
||||
}
|
||||
|
||||
function sanitizeFileName(name: string) {
|
||||
const fileName = basename(name.replaceAll("\\", "/")).trim();
|
||||
if (
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Clock,
|
||||
Circle,
|
||||
FileIcon,
|
||||
FolderOpen,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
TriangleAlert,
|
||||
@@ -42,6 +43,7 @@ import { formatModel } from "@/lib/model";
|
||||
import { lastTextOf, type SubAgentStep } from "@/lib/subAgentActivity";
|
||||
import { useStickToBottom } from "use-stick-to-bottom";
|
||||
import { FilesPopover } from "@/app/components/TasksFilesSidebar";
|
||||
import { WorkspacePanel } from "@/app/components/WorkspacePanel";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -96,7 +98,9 @@ const getStatusIcon = (status: TodoItem["status"], className?: string) => {
|
||||
};
|
||||
|
||||
export const ChatInterface = React.memo<ChatInterfaceProps>(({ assistant }) => {
|
||||
const [metaOpen, setMetaOpen] = useState<"tasks" | "files" | null>(null);
|
||||
const [metaOpen, setMetaOpen] = useState<
|
||||
"tasks" | "files" | "workspace" | null
|
||||
>(null);
|
||||
const tasksContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||
@@ -649,7 +653,9 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(({ assistant }) => {
|
||||
"focus-within:ring-2 focus-within:ring-ring"
|
||||
)}
|
||||
>
|
||||
{(hasTasks || hasFiles) && (
|
||||
{/* Always rendered: the Workspace tab is available even with no tasks
|
||||
or state files yet. */}
|
||||
{
|
||||
<div className="flex max-h-72 flex-col overflow-y-auto border-b border-border bg-sidebar empty:hidden">
|
||||
{!metaOpen && (
|
||||
<>
|
||||
@@ -756,10 +762,27 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(({ assistant }) => {
|
||||
);
|
||||
})();
|
||||
|
||||
const workspaceTrigger = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setMetaOpen((prev) =>
|
||||
prev === "workspace" ? null : "workspace"
|
||||
)
|
||||
}
|
||||
className="flex flex-shrink-0 cursor-pointer items-center gap-2 px-[18px] py-3 text-left text-sm"
|
||||
aria-expanded={metaOpen === "workspace"}
|
||||
>
|
||||
<FolderOpen size={16} />
|
||||
Workspace
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[1fr_auto_auto] items-center">
|
||||
{tasksTrigger}
|
||||
<div className="flex items-center">
|
||||
<div className="min-w-0 flex-1">{tasksTrigger}</div>
|
||||
{filesTrigger}
|
||||
{workspaceTrigger}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
@@ -800,6 +823,18 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(({ assistant }) => {
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 py-3 pr-4 first:pl-[18px] aria-expanded:font-semibold"
|
||||
onClick={() =>
|
||||
setMetaOpen((prev) =>
|
||||
prev === "workspace" ? null : "workspace"
|
||||
)
|
||||
}
|
||||
aria-expanded={metaOpen === "workspace"}
|
||||
>
|
||||
Workspace
|
||||
</button>
|
||||
<button
|
||||
aria-label="Close"
|
||||
className="flex-1"
|
||||
@@ -851,11 +886,17 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(({ assistant }) => {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{metaOpen === "workspace" && (
|
||||
<div className="mb-6 pt-2">
|
||||
<WorkspacePanel />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
{autoApprove && (
|
||||
<div
|
||||
aria-live="polite"
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useState, useEffect } from "react";
|
||||
import { Download, Loader2, FileText } from "lucide-react";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import { MarkdownContent } from "@/app/components/MarkdownContent";
|
||||
|
||||
const LANGUAGE_MAP: Record<string, string> = {
|
||||
js: "javascript",
|
||||
jsx: "javascript",
|
||||
ts: "typescript",
|
||||
tsx: "typescript",
|
||||
py: "python",
|
||||
rb: "ruby",
|
||||
go: "go",
|
||||
rs: "rust",
|
||||
java: "java",
|
||||
cpp: "cpp",
|
||||
c: "c",
|
||||
cs: "csharp",
|
||||
php: "php",
|
||||
swift: "swift",
|
||||
kt: "kotlin",
|
||||
sh: "bash",
|
||||
bash: "bash",
|
||||
zsh: "bash",
|
||||
json: "json",
|
||||
jsonl: "json",
|
||||
xml: "xml",
|
||||
html: "html",
|
||||
css: "css",
|
||||
scss: "scss",
|
||||
sql: "sql",
|
||||
yaml: "yaml",
|
||||
yml: "yaml",
|
||||
toml: "toml",
|
||||
ini: "ini",
|
||||
tex: "latex",
|
||||
bib: "latex",
|
||||
r: "r",
|
||||
};
|
||||
|
||||
const IMAGE_EXTS = new Set([
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"gif",
|
||||
"webp",
|
||||
"svg",
|
||||
"bmp",
|
||||
]);
|
||||
// Extensions we render as text. Anything not here and not an image/pdf is
|
||||
// treated as a binary download.
|
||||
const TEXT_EXTS = new Set([
|
||||
...Object.keys(LANGUAGE_MAP),
|
||||
"txt",
|
||||
"md",
|
||||
"markdown",
|
||||
"log",
|
||||
"csv",
|
||||
"tsv",
|
||||
"cfg",
|
||||
"conf",
|
||||
"env",
|
||||
"gitignore",
|
||||
]);
|
||||
// Inline text preview is capped — bigger files are offered as a download so we
|
||||
// never pull tens of MB into the browser just to render it.
|
||||
const MAX_INLINE_TEXT_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
export function workspaceFileUrl(path: string, download = false): string {
|
||||
const qs = new URLSearchParams({ path });
|
||||
if (download) qs.set("download", "1");
|
||||
return `/api/workspace/file?${qs.toString()}`;
|
||||
}
|
||||
|
||||
type Kind = "text" | "image" | "pdf" | "binary";
|
||||
|
||||
function kindOf(ext: string): Kind {
|
||||
if (IMAGE_EXTS.has(ext)) return "image";
|
||||
if (ext === "pdf") return "pdf";
|
||||
if (TEXT_EXTS.has(ext)) return "text";
|
||||
return "binary";
|
||||
}
|
||||
|
||||
export const WorkspaceFileDialog = React.memo<{
|
||||
/** Path relative to the workspace root, or null to close. */
|
||||
path: string | null;
|
||||
/** Byte size from the listing — used to gate inline text preview. */
|
||||
size?: number;
|
||||
onClose: () => void;
|
||||
}>(({ path, size, onClose }) => {
|
||||
const [content, setContent] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const name = path ? path.split("/").pop() || path : "";
|
||||
const ext = useMemo(
|
||||
() => (name.includes(".") ? name.split(".").pop()!.toLowerCase() : ""),
|
||||
[name]
|
||||
);
|
||||
const kind = kindOf(ext);
|
||||
const tooBigForText =
|
||||
kind === "text" && size != null && size > MAX_INLINE_TEXT_BYTES;
|
||||
|
||||
useEffect(() => {
|
||||
if (!path || kind !== "text" || tooBigForText) {
|
||||
setContent(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetch(workspaceFileUrl(path))
|
||||
.then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => null);
|
||||
throw new Error(body?.error || `Failed to load file (${res.status})`);
|
||||
}
|
||||
return res.text();
|
||||
})
|
||||
.then((text) => {
|
||||
if (!cancelled) setContent(text);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(err.message ?? "Failed to load file.");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [path, kind, tooBigForText]);
|
||||
|
||||
if (!path) return null;
|
||||
|
||||
const isMarkdown = ext === "md" || ext === "markdown";
|
||||
const language = LANGUAGE_MAP[ext] || "text";
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={true}
|
||||
onOpenChange={onClose}
|
||||
>
|
||||
<DialogContent className="flex h-[80vh] max-h-[80vh] min-w-[60vw] flex-col p-6">
|
||||
<DialogTitle className="sr-only">{path}</DialogTitle>
|
||||
<div className="mb-4 flex items-center justify-between border-b border-border pb-4">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<FileText className="text-primary/50 h-5 w-5 shrink-0" />
|
||||
<span className="overflow-hidden text-ellipsis whitespace-nowrap text-base font-medium text-primary">
|
||||
{path}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<a
|
||||
href={workspaceFileUrl(path, true)}
|
||||
download={name}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2"
|
||||
asChild
|
||||
>
|
||||
<span>
|
||||
<Download
|
||||
size={16}
|
||||
className="mr-1"
|
||||
/>
|
||||
Download
|
||||
</span>
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
{kind === "image" ? (
|
||||
<ScrollArea className="bg-surface h-full rounded-md">
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<img
|
||||
src={workspaceFileUrl(path)}
|
||||
alt={name}
|
||||
className="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : kind === "pdf" ? (
|
||||
<iframe
|
||||
src={workspaceFileUrl(path)}
|
||||
title={name}
|
||||
className="h-full w-full rounded-md border border-border"
|
||||
/>
|
||||
) : kind === "binary" || tooBigForText ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 p-12 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{tooBigForText
|
||||
? "This file is too large to preview inline."
|
||||
: "This file type can't be previewed."}
|
||||
</p>
|
||||
<a
|
||||
href={workspaceFileUrl(path, true)}
|
||||
download={name}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<Download
|
||||
size={16}
|
||||
className="mr-1"
|
||||
/>
|
||||
Download file
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex h-full items-center justify-center p-12">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="bg-surface h-full rounded-md">
|
||||
<div className="p-4">
|
||||
{content && content.length > 0 ? (
|
||||
isMarkdown ? (
|
||||
<div className="rounded-md p-6">
|
||||
<MarkdownContent content={content} />
|
||||
</div>
|
||||
) : (
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={oneDark}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
borderRadius: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
showLineNumbers
|
||||
wrapLines={true}
|
||||
lineProps={{ style: { whiteSpace: "pre-wrap" } }}
|
||||
>
|
||||
{content}
|
||||
</SyntaxHighlighter>
|
||||
)
|
||||
) : (
|
||||
<div className="flex items-center justify-center p-12">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
File is empty
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
});
|
||||
|
||||
WorkspaceFileDialog.displayName = "WorkspaceFileDialog";
|
||||
@@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Folder,
|
||||
FileText,
|
||||
RefreshCw,
|
||||
Download,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
WorkspaceFileDialog,
|
||||
} from "@/app/components/WorkspaceFileDialog";
|
||||
import type { WorkspaceEntry } from "@/app/api/workspace/route";
|
||||
|
||||
async function listDir(path: string): Promise<WorkspaceEntry[]> {
|
||||
const res = await fetch(`/api/workspace?${new URLSearchParams({ path })}`);
|
||||
const body = await res.json().catch(() => null);
|
||||
if (!res.ok) throw new Error(body?.error || "Failed to list workspace.");
|
||||
return (body?.entries ?? []) as WorkspaceEntry[];
|
||||
}
|
||||
|
||||
export function WorkspacePanel() {
|
||||
// Listing cache keyed by directory path ("" = workspace root).
|
||||
const [children, setChildren] = useState<Record<string, WorkspaceEntry[]>>(
|
||||
{}
|
||||
);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [loading, setLoading] = useState<Set<string>>(new Set());
|
||||
const [rootLoading, setRootLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<{
|
||||
path: string;
|
||||
size: number;
|
||||
} | null>(null);
|
||||
|
||||
const loadDir = useCallback(async (path: string) => {
|
||||
setLoading((prev) => new Set(prev).add(path));
|
||||
try {
|
||||
const entries = await listDir(path);
|
||||
setChildren((prev) => ({ ...prev, [path]: entries }));
|
||||
if (path === "") setError(null);
|
||||
return entries;
|
||||
} catch (err) {
|
||||
if (path === "") {
|
||||
setError(err instanceof Error ? err.message : "Failed to load.");
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(path);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setRootLoading(true);
|
||||
// Re-fetch the root plus every currently-expanded directory so an open tree
|
||||
// stays open and in sync with what the agent has written since.
|
||||
const toLoad = ["", ...expanded];
|
||||
await Promise.allSettled(toLoad.map((p) => loadDir(p)));
|
||||
setRootLoading(false);
|
||||
}, [expanded, loadDir]);
|
||||
|
||||
// Initial load. loadDir surfaces root failures via `error` state; catch the
|
||||
// rejection here so it doesn't become an unhandled promise rejection.
|
||||
useEffect(() => {
|
||||
setRootLoading(true);
|
||||
void loadDir("")
|
||||
.catch(() => {})
|
||||
.finally(() => setRootLoading(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const toggleDir = useCallback(
|
||||
(path: string) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(path)) {
|
||||
next.delete(path);
|
||||
} else {
|
||||
next.add(path);
|
||||
if (!children[path]) void loadDir(path).catch(() => {});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[children, loadDir]
|
||||
);
|
||||
|
||||
const renderEntries = (path: string, depth: number): React.ReactNode => {
|
||||
const entries = children[path];
|
||||
if (!entries) return null;
|
||||
if (entries.length === 0 && depth === 0) {
|
||||
return (
|
||||
<p className="px-2 py-6 text-center text-xs text-muted-foreground">
|
||||
No files in the workspace yet
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return entries.map((entry) => {
|
||||
const isOpen = expanded.has(entry.path);
|
||||
const isLoadingDir = loading.has(entry.path);
|
||||
return (
|
||||
<div key={entry.path}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
entry.type === "dir"
|
||||
? toggleDir(entry.path)
|
||||
: setSelected({ path: entry.path, size: entry.size })
|
||||
}
|
||||
className="flex w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left text-sm text-foreground transition-colors hover:bg-muted"
|
||||
style={{ paddingLeft: `${depth * 14 + 4}px` }}
|
||||
title={entry.name}
|
||||
>
|
||||
{entry.type === "dir" ? (
|
||||
<>
|
||||
<span className="flex size-4 shrink-0 items-center justify-center text-muted-foreground">
|
||||
{isLoadingDir ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : isOpen ? (
|
||||
<ChevronDown className="size-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5" />
|
||||
)}
|
||||
</span>
|
||||
<Folder className="size-4 shrink-0 text-[var(--brand)]" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="size-4 shrink-0" />
|
||||
<FileText className="size-4 shrink-0 text-muted-foreground" />
|
||||
</>
|
||||
)}
|
||||
<span className="truncate">{entry.name}</span>
|
||||
</button>
|
||||
{entry.type === "dir" && isOpen && renderEntries(entry.path, depth + 1)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-col">
|
||||
<div className="flex items-center justify-between gap-2 pb-1.5">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-tertiary">
|
||||
Working directory
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<a
|
||||
href="/api/workspace/download"
|
||||
download
|
||||
className="inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
title="Download the whole workspace as a zip"
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
All
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={refresh}
|
||||
disabled={rootLoading}
|
||||
className="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50"
|
||||
aria-label="Refresh workspace"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn("size-3.5", rootLoading && "animate-spin")}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="px-2 py-6 text-center text-xs text-muted-foreground">
|
||||
{error}
|
||||
</p>
|
||||
) : rootLoading && !children[""] ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="-mx-1">{renderEntries("", 0)}</div>
|
||||
)}
|
||||
|
||||
<WorkspaceFileDialog
|
||||
path={selected?.path ?? null}
|
||||
size={selected?.size}
|
||||
onClose={() => setSelected(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
// Server-only helpers for talking to the active EvoScientist deployment's
|
||||
// on-disk workspace. Shared by the workspace upload/list/read API routes.
|
||||
//
|
||||
// The "workspace" is the working directory of the currently running langgraph
|
||||
// dev (where the agent reads/writes real files via its file tools). It is NOT
|
||||
// the agent's in-memory `files` state — that lives in thread state and is
|
||||
// managed through the SDK.
|
||||
|
||||
import { promises as fs } from "fs";
|
||||
import { homedir } from "os";
|
||||
import { join, relative, resolve, sep } from "path";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export const WORKSPACE_SIDECAR = join(
|
||||
homedir(),
|
||||
".config",
|
||||
"evoscientist",
|
||||
"langgraph_dev.workspace.json"
|
||||
);
|
||||
|
||||
interface WorkspaceSidecar {
|
||||
workspace?: unknown;
|
||||
pid?: unknown;
|
||||
}
|
||||
|
||||
/** True if the name contains any C0/C1 control char, DEL, or a line/paragraph
|
||||
* separator. A newline in a filename would otherwise be spliced into the
|
||||
* prompt sent to the agent (instruction injection); control chars have no
|
||||
* place in a filename. */
|
||||
export function hasControlChar(name: string): boolean {
|
||||
for (let i = 0; i < name.length; i += 1) {
|
||||
const code = name.charCodeAt(i);
|
||||
if (
|
||||
code < 0x20 || // C0 controls (incl. NUL, tab, newline)
|
||||
code === 0x7f || // DEL
|
||||
(code >= 0x80 && code <= 0x9f) || // C1 controls
|
||||
code === 0x2028 || // line separator
|
||||
code === 0x2029 // paragraph separator
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// What the workspace browser hides
|
||||
//
|
||||
// Single source of truth for "noise" the file tree, direct file access, and the
|
||||
// download-all zip all agree on. Edit these lists to change what's shown.
|
||||
// Matched case-sensitively against each path segment.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Exact entry names treated as internal/noise — never research artifacts. */
|
||||
export const IGNORED_NAMES = new Set([
|
||||
"large_tool_results", // EvoScientist: large tool outputs spilled to disk, keyed by call id
|
||||
"conversation_history", // EvoScientist: internal conversation transcripts
|
||||
"__pycache__", // Python bytecode cache
|
||||
"node_modules", // JS deps
|
||||
"__MACOSX", // macOS archive cruft
|
||||
]);
|
||||
|
||||
/** Filename suffixes hidden the same way (build artifacts). */
|
||||
export const IGNORED_SUFFIXES = [".pyc", ".pyo"];
|
||||
|
||||
/**
|
||||
* True if a single entry name should be hidden from the workspace browser:
|
||||
* dotfiles (incl. `.langgraph_api`, `.DS_Store`), known-internal directories,
|
||||
* and build artifacts. Used for both listings and direct-access blocking.
|
||||
*/
|
||||
export function isHiddenEntry(name: string): boolean {
|
||||
if (name.startsWith(".")) return true;
|
||||
if (IGNORED_NAMES.has(name)) return true;
|
||||
return IGNORED_SUFFIXES.some((suffix) => name.endsWith(suffix));
|
||||
}
|
||||
|
||||
/** `zip -x` exclude args derived from the same ignore lists, so the download
|
||||
* archive contains exactly what the tree shows. */
|
||||
export function zipExcludeArgs(): string[] {
|
||||
const patterns = [".*", "*/.*"]; // dotfiles at root and nested
|
||||
for (const name of IGNORED_NAMES) {
|
||||
patterns.push(`${name}/*`, `*/${name}/*`, name, `*/${name}`);
|
||||
}
|
||||
for (const suffix of IGNORED_SUFFIXES) {
|
||||
patterns.push(`*${suffix}`);
|
||||
}
|
||||
return patterns.flatMap((pattern) => ["-x", pattern]);
|
||||
}
|
||||
|
||||
/** True if a process with `pid` is currently running (signal 0 = existence probe). */
|
||||
export function isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
// EPERM means the process exists but we may not signal it — still alive.
|
||||
return (error as NodeJS.ErrnoException).code === "EPERM";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the workspace of the *currently running* EvoScientist deployment.
|
||||
*
|
||||
* The sidecar records `{ workspace, pid }` of the langgraph dev that owns this
|
||||
* workspace. We only trust it when that pid is still alive — a stale sidecar
|
||||
* from a crashed/previous session must not silently redirect file access to a
|
||||
* directory the live deployment no longer uses. Falls back to the launcher env.
|
||||
*/
|
||||
export async function getWorkspaceDir(): Promise<string> {
|
||||
let workspace: string | undefined;
|
||||
try {
|
||||
const sidecar = JSON.parse(
|
||||
await fs.readFile(WORKSPACE_SIDECAR, "utf-8")
|
||||
) as WorkspaceSidecar;
|
||||
const ws = sidecar.workspace;
|
||||
const pid = sidecar.pid;
|
||||
if (typeof ws === "string" && ws.trim()) {
|
||||
const hasPid = typeof pid === "number" && pid > 0;
|
||||
// With a recorded backend pid, only trust the sidecar while that process
|
||||
// is alive; older sidecars without one fall back to trusting it as before.
|
||||
if (!hasPid || isProcessAlive(pid as number)) workspace = ws;
|
||||
}
|
||||
} catch {
|
||||
// Older/manual setups may not have a sidecar. Fall back to the launcher env.
|
||||
}
|
||||
|
||||
workspace ||= process.env.EVOSCIENTIST_WORKSPACE_DIR;
|
||||
if (!workspace) {
|
||||
throw new Error(
|
||||
"No active EvoScientist workspace found. Start the backend with `EvoSci deploy` first."
|
||||
);
|
||||
}
|
||||
|
||||
const resolved = resolve(workspace);
|
||||
const stat = await fs.stat(resolved);
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error("The active EvoScientist workspace is not a directory.");
|
||||
}
|
||||
// Canonicalize so every containment check compares against the *real* root —
|
||||
// the workspace (or a parent) may itself live under a symlink (e.g. macOS
|
||||
// /tmp -> /private/tmp), which would otherwise break startsWith() checks.
|
||||
return fs.realpath(resolved);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a caller-supplied relative path against `workspaceDir` and guarantee
|
||||
* the result stays inside it (no `..` escape, no absolute-path override, no
|
||||
* control chars). Returns the absolute, normalized path.
|
||||
*
|
||||
* `relPath` is treated as relative even if it starts with `/` — a leading slash
|
||||
* is stripped so an absolute path can never replace the workspace root.
|
||||
*/
|
||||
export function resolveInside(workspaceDir: string, relPath: string): string {
|
||||
if (hasControlChar(relPath)) {
|
||||
throw new Error("Invalid path.");
|
||||
}
|
||||
// Normalize separators and strip any leading slashes so the path is always
|
||||
// interpreted relative to the workspace root.
|
||||
const cleaned = relPath.replaceAll("\\", "/").replace(/^\/+/, "");
|
||||
// Hidden entries (dotfiles like `.langgraph_api`, internal dirs like
|
||||
// `large_tool_results`, build noise) are blocked from direct access too — not
|
||||
// just unlisted — so a crafted `?path=.langgraph_api` or `?path=large_tool_results`
|
||||
// can't read them. `..` is also caught here (and by the boundary check below).
|
||||
if (cleaned.split("/").some((seg) => seg !== "" && isHiddenEntry(seg))) {
|
||||
throw new Error("Path is not accessible.");
|
||||
}
|
||||
const target = resolve(workspaceDir, cleaned);
|
||||
// Must be the workspace dir itself or strictly within it.
|
||||
if (target !== workspaceDir && !target.startsWith(workspaceDir + sep)) {
|
||||
throw new Error("Path is outside the workspace.");
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `resolveInside`, but ALSO defeats symlink escapes: it canonicalizes the
|
||||
* target with `fs.realpath` and re-checks containment + the hidden-entry policy
|
||||
* against the real path. A symlink such as `out -> /etc` (so `out/passwd` is
|
||||
* lexically "inside") or `link -> .langgraph_api` is rejected here, even though
|
||||
* the lexical check in `resolveInside` would pass.
|
||||
*
|
||||
* `workspaceDir` MUST already be canonical (it is — `getWorkspaceDir` realpaths
|
||||
* it). Throws if the target doesn't exist or escapes. Use this for any access
|
||||
* that will then follow the path on disk (stat/read/list).
|
||||
*/
|
||||
export async function safeResolve(
|
||||
workspaceDir: string,
|
||||
relPath: string
|
||||
): Promise<string> {
|
||||
const target = resolveInside(workspaceDir, relPath);
|
||||
let realTarget: string;
|
||||
try {
|
||||
realTarget = await fs.realpath(target);
|
||||
} catch {
|
||||
// Missing file or a broken/looping symlink — treat as inaccessible.
|
||||
throw new Error("Path is not accessible.");
|
||||
}
|
||||
if (
|
||||
realTarget !== workspaceDir &&
|
||||
!realTarget.startsWith(workspaceDir + sep)
|
||||
) {
|
||||
throw new Error("Path is not accessible.");
|
||||
}
|
||||
// A symlink could point at a hidden/internal entry that lives inside the
|
||||
// workspace (e.g. `foo -> .langgraph_api`); re-check the canonical segments.
|
||||
const realRel = relative(workspaceDir, realTarget);
|
||||
if (
|
||||
realRel &&
|
||||
realRel.split(sep).some((seg) => seg !== "" && isHiddenEntry(seg))
|
||||
) {
|
||||
throw new Error("Path is not accessible.");
|
||||
}
|
||||
return realTarget;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject cross-site requests to the workspace APIs. Browsers omit `Origin` on
|
||||
* same-origin GETs and on direct navigations (open-in-tab / downloads), so we
|
||||
* lean on `Sec-Fetch-Site` when present and fall back to an Origin check.
|
||||
*/
|
||||
export function isCrossOrigin(request: NextRequest): boolean {
|
||||
const site = request.headers.get("sec-fetch-site");
|
||||
if (
|
||||
site &&
|
||||
site !== "same-origin" &&
|
||||
site !== "same-site" &&
|
||||
site !== "none"
|
||||
) {
|
||||
return true; // explicit cross-site request
|
||||
}
|
||||
const origin = request.headers.get("origin");
|
||||
return !!origin && origin !== request.nextUrl.origin;
|
||||
}
|
||||
Reference in New Issue
Block a user