From bbb5c888f2f86a1cfeb7395cdcaed62f64357b36 Mon Sep 17 00:00:00 2001 From: Xi Zhang Date: Wed, 3 Jun 2026 02:34:04 +0100 Subject: [PATCH] feat: update version to 0.0.3 and improve error handling in API routes --- package-lock.json | 4 +- package.json | 2 +- src/app/api/skills/detail/route.ts | 9 +- src/app/api/skills/route.ts | 32 ++++++-- src/app/api/workspace/file/route.ts | 12 ++- src/app/api/workspace/route.ts | 9 +- src/app/components/SkillDetailDialog.tsx | 15 +++- src/app/components/SkillsMarketplace.tsx | 52 ++++++++---- src/app/components/WorkspaceFileDialog.tsx | 10 +-- src/app/components/WorkspacePanel.tsx | 96 +++++++++++++++++++--- src/lib/server/skills.ts | 16 ++-- 11 files changed, 186 insertions(+), 71 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9d82fe9..2d68571 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@evoscientist/webui", - "version": "0.0.2", + "version": "0.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@evoscientist/webui", - "version": "0.0.2", + "version": "0.0.3", "license": "Apache-2.0", "dependencies": { "@langchain/core": "1.1.19", diff --git a/package.json b/package.json index 9431ea3..ad910de 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@evoscientist/webui", - "version": "0.0.2", + "version": "0.0.3", "description": "Web UI for EvoScientist — a self-evolving AI scientist built on DeepAgents/LangGraph.", "author": "Xi Zhang ", "type": "module", diff --git a/src/app/api/skills/detail/route.ts b/src/app/api/skills/detail/route.ts index f885e73..5f6ca44 100644 --- a/src/app/api/skills/detail/route.ts +++ b/src/app/api/skills/detail/route.ts @@ -7,7 +7,10 @@ export async function GET(request: NextRequest) { try { const name = request.nextUrl.searchParams.get("name"); if (!name) { - return NextResponse.json({ error: "Missing skill name." }, { status: 400 }); + return NextResponse.json( + { error: "Missing skill name." }, + { status: 400 } + ); } const detail = await getSkillDetail(name); return NextResponse.json(detail); @@ -15,7 +18,9 @@ export async function GET(request: NextRequest) { return NextResponse.json( { error: - error instanceof Error ? error.message : "Failed to load skill detail.", + error instanceof Error + ? error.message + : "Failed to load skill detail.", }, { status: 400 } ); diff --git a/src/app/api/skills/route.ts b/src/app/api/skills/route.ts index 1224bef..340c81c 100644 --- a/src/app/api/skills/route.ts +++ b/src/app/api/skills/route.ts @@ -1,7 +1,11 @@ import { NextRequest, NextResponse } from "next/server"; -import { join, resolve } from "path"; +import { join, resolve, sep } from "path"; import { promises as fs } from "fs"; -import { SKILL_DIRS, recordUninstall } from "@/lib/server/skills"; +import { + SKILL_DIRS, + recordUninstall, + isValidSkillName, +} from "@/lib/server/skills"; // SKILL_DIRS (the global ~/.evoscientist/skills tier + legacy ~/.config // fallback) is the single source of truth, shared with the install route. @@ -34,8 +38,10 @@ async function readSkills(): Promise { const seen = new Set(); for (const dir of SKILL_DIRS) { let entries: string[] = []; + let realRoot: string; try { entries = await fs.readdir(dir); + realRoot = await fs.realpath(dir); } catch { continue; // dir doesn't exist } @@ -43,9 +49,15 @@ async function readSkills(): Promise { if (entry.startsWith(".")) continue; const skillDir = join(dir, entry); try { - const stat = await fs.stat(skillDir); + // Canonicalize so a symlinked skill dir / SKILL.md can't read outside + // the tier (consistent with getSkillDetail's guard). + const realDir = await fs.realpath(skillDir); + if (realDir !== realRoot && !realDir.startsWith(realRoot + sep)) { + continue; + } + const stat = await fs.stat(realDir); if (!stat.isDirectory()) continue; - const md = await fs.readFile(join(skillDir, "SKILL.md"), "utf-8"); + const md = await fs.readFile(join(realDir, "SKILL.md"), "utf-8"); const { name, description } = parseFrontmatter(md); // Identity is the DIRECTORY name (what install/uninstall/dedup key on); // the frontmatter name is display-only. @@ -81,14 +93,20 @@ export async function GET() { // only delete inside the known skill dirs. export async function DELETE(req: NextRequest) { const name = req.nextUrl.searchParams.get("name"); - if (!name || /[\\/]|\.\./.test(name)) { + // Strict name check (blocks dotfiles like `.installed.yaml`, traversal, odd + // chars) — must match install-side validation, not the old slash/`..`-only one. + if (!name || !isValidSkillName(name)) { return NextResponse.json({ error: "Invalid skill name" }, { status: 400 }); } for (const dir of SKILL_DIRS) { const target = resolve(join(dir, name)); - if (!target.startsWith(resolve(dir) + "/")) continue; + if (target !== resolve(dir) && !target.startsWith(resolve(dir) + sep)) { + continue; + } try { - await fs.stat(target); + // Only ever remove an actual skill directory, never a stray file. + const stat = await fs.stat(target); + if (!stat.isDirectory()) continue; } catch { continue; // not here } diff --git a/src/app/api/workspace/file/route.ts b/src/app/api/workspace/file/route.ts index 4f412cb..4d3043c 100644 --- a/src/app/api/workspace/file/route.ts +++ b/src/app/api/workspace/file/route.ts @@ -18,7 +18,9 @@ function contentDisposition(fileName: string, asAttachment: boolean): string { /['()*]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase() ); - return `${asAttachment ? "attachment" : "inline"}; filename="${ascii}"; filename*=UTF-8''${encoded}`; + return `${ + asAttachment ? "attachment" : "inline" + }; filename="${ascii}"; filename*=UTF-8''${encoded}`; } export const runtime = "nodejs"; @@ -77,10 +79,7 @@ export async function GET(request: NextRequest) { const stat = await fs.stat(target); if (!stat.isFile()) { - return NextResponse.json( - { error: "Not a file." }, - { status: 400 } - ); + return NextResponse.json({ error: "Not a file." }, { status: 400 }); } const ext = extname(target).slice(1).toLowerCase(); @@ -111,8 +110,7 @@ export async function GET(request: NextRequest) { } catch (error) { return NextResponse.json( { - error: - error instanceof Error ? error.message : "Failed to read file.", + error: error instanceof Error ? error.message : "Failed to read file.", }, { status: 400 } ); diff --git a/src/app/api/workspace/route.ts b/src/app/api/workspace/route.ts index 68ad228..dffd934 100644 --- a/src/app/api/workspace/route.ts +++ b/src/app/api/workspace/route.ts @@ -95,10 +95,7 @@ export async function GET(request: NextRequest) { const stat = await fs.stat(dir); if (!stat.isDirectory()) { - return NextResponse.json( - { error: "Not a directory." }, - { status: 400 } - ); + return NextResponse.json({ error: "Not a directory." }, { status: 400 }); } // "By type" view: flat list of every file under the workspace. @@ -169,9 +166,7 @@ export async function GET(request: NextRequest) { return NextResponse.json( { error: - error instanceof Error - ? error.message - : "Failed to list workspace.", + error instanceof Error ? error.message : "Failed to list workspace.", }, { status: 400 } ); diff --git a/src/app/components/SkillDetailDialog.tsx b/src/app/components/SkillDetailDialog.tsx index 0ee70c7..8def5c4 100644 --- a/src/app/components/SkillDetailDialog.tsx +++ b/src/app/components/SkillDetailDialog.tsx @@ -39,7 +39,8 @@ export const SkillDetailDialog = React.memo<{ fetch(`/api/skills/detail?name=${encodeURIComponent(skill.name)}`) .then(async (res) => { const d = await res.json().catch(() => null); - if (!res.ok) throw new Error(d?.error || `Failed to load (${res.status})`); + if (!res.ok) + throw new Error(d?.error || `Failed to load (${res.status})`); return d as FetchedDetail; }) .then((d) => { @@ -92,8 +93,11 @@ export const SkillDetailDialog = React.memo<{ )} {installed && ( - -