diff --git a/CHANGELOG.md b/CHANGELOG.md index df47f2076..472bb5b53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ Only write entries that are worth mentioning to users. ## Unreleased +- Web: Fix file listing crash on macOS when encountering permission-restricted entries (e.g. `.Trash`) — `PermissionError` on `iterdir()` now returns an empty listing, and individual entries that raise `PermissionError` / `OSError` on `stat()` are silently skipped + ## 1.29.0 (2026-04-01) - Core: Support hierarchical `AGENTS.md` loading — the CLI now discovers and merges `AGENTS.md` files from the git project root down to the working directory, including `.kimi/AGENTS.md` at each level; deeper files take priority under a 32 KiB budget cap, ensuring the most specific instructions are never truncated diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index 5919a1ef1..ce936a238 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -4,6 +4,8 @@ This page documents the changes in each Kimi Code CLI release. ## Unreleased +- Web: Fix file listing crash on macOS when encountering permission-restricted entries (e.g. `.Trash`) — `PermissionError` on `iterdir()` now returns an empty listing, and individual entries that raise `PermissionError` / `OSError` on `stat()` are silently skipped + ## 1.29.0 (2026-04-01) - Core: Support hierarchical `AGENTS.md` loading — the CLI now discovers and merges `AGENTS.md` files from the git project root down to the working directory, including `.kimi/AGENTS.md` at each level; deeper files take priority under a 32 KiB budget cap, ensuring the most specific instructions are never truncated diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index aca27bfa0..1cd42d1d0 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -4,6 +4,8 @@ ## 未发布 +- Web:修复 macOS 上遇到权限受限条目(如 `.Trash`)时文件列表崩溃的问题——`iterdir()` 的 `PermissionError` 现在返回空列表,`stat()` 时抛出 `PermissionError` / `OSError` 的单个条目会被静默跳过 + ## 1.29.0 (2026-04-01) - Core:支持层级化 `AGENTS.md` 加载——CLI 现在会从 git 项目根目录到工作目录逐层发现并合并 `AGENTS.md` 文件,包括每层目录中的 `.kimi/AGENTS.md`;在 32 KiB 预算上限下,更深层目录的文件优先保留,确保最具体的指令不会被截断 diff --git a/src/kimi_cli/web/api/sessions.py b/src/kimi_cli/web/api/sessions.py index 801d74f69..6bd934dd4 100644 --- a/src/kimi_cli/web/api/sessions.py +++ b/src/kimi_cli/web/api/sessions.py @@ -483,8 +483,12 @@ async def get_session_file( requested_path = work_dir / path file_path = requested_path.resolve() - # Check path traversal + # Check path traversal — symlinks pointing outside work_dir are not an + # attack, but we must not serve their content. For directories we return + # an empty listing so the frontend BFS can continue; for files we 404. if not file_path.is_relative_to(work_dir): + if requested_path.is_symlink(): + return Response(content="[]", media_type="application/json") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid path: path traversal not allowed", @@ -522,21 +526,28 @@ async def get_session_file( if file_path.is_dir(): result: list[dict[str, str | int]] = [] - for subpath in file_path.iterdir(): + try: + entries = list(file_path.iterdir()) + except PermissionError: + entries = [] + for subpath in entries: if restrict_sensitive_apis: rel_subpath = rel_path / subpath.name if _is_sensitive_relative_path(rel_subpath): continue - if subpath.is_dir(): - result.append({"name": subpath.name, "type": "directory"}) - else: - result.append( - { - "name": subpath.name, - "type": "file", - "size": subpath.stat().st_size, - } - ) + try: + if subpath.is_dir(): + result.append({"name": subpath.name, "type": "directory"}) + else: + result.append( + { + "name": subpath.name, + "type": "file", + "size": subpath.stat().st_size, + } + ) + except (PermissionError, OSError): + continue result.sort(key=lambda x: (cast(str, x["type"]), cast(str, x["name"]))) return Response(content=json.dumps(result), media_type="application/json") diff --git a/tests/web/test_sessions_file_listing.py b/tests/web/test_sessions_file_listing.py new file mode 100644 index 000000000..cb8b01336 --- /dev/null +++ b/tests/web/test_sessions_file_listing.py @@ -0,0 +1,106 @@ +"""Tests for file listing in sessions API — PermissionError and symlink handling.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +import pytest +from starlette.testclient import TestClient + +from kimi_cli.web.app import create_app + + +@pytest.fixture +def client(): + app = create_app(lan_only=False) + with TestClient(app) as c: + yield c + + +@pytest.fixture +def session_with_work_dir(client: TestClient, tmp_path: Path): + """Create a session pointing to tmp_path as work_dir.""" + resp = client.post("/api/sessions/", json={"work_dir": str(tmp_path)}) + assert resp.status_code == 200 + return resp.json()["session_id"], tmp_path + + +def test_list_dir_skips_permission_error_on_iterdir( + client: TestClient, session_with_work_dir: tuple[str, Path] +): + """iterdir() raising PermissionError (e.g. macOS .Trash) should not crash.""" + session_id, work_dir = session_with_work_dir + + # Create a normal file so we can verify endpoint works + (work_dir / "hello.txt").write_text("hi") + + original_iterdir = Path.iterdir + + def patched_iterdir(self: Path): + if self == work_dir: + raise PermissionError("Operation not permitted") + return original_iterdir(self) + + with patch.object(Path, "iterdir", patched_iterdir): + resp = client.get(f"/api/sessions/{session_id}/files/") + assert resp.status_code == 200 + data = resp.json() + # iterdir failed on the directory, so result should be empty + assert data == [] + + +def test_list_dir_skips_individual_stat_error( + client: TestClient, session_with_work_dir: tuple[str, Path] +): + """Individual entry stat() failure should be skipped, not crash.""" + session_id, work_dir = session_with_work_dir + + (work_dir / "good.txt").write_text("ok") + (work_dir / "bad_entry").mkdir() + + original_is_dir = Path.is_dir + + def patched_is_dir(self: Path): + if self.name == "bad_entry": + raise PermissionError("Operation not permitted") + return original_is_dir(self) + + with patch.object(Path, "is_dir", patched_is_dir): + resp = client.get(f"/api/sessions/{session_id}/files/") + assert resp.status_code == 200 + data = resp.json() + names = [e["name"] for e in data] + assert "good.txt" in names + assert "bad_entry" not in names + + +def test_symlink_outside_work_dir_returns_empty_listing(client: TestClient, tmp_path: Path): + """Symlink pointing outside work_dir should return empty JSON list, not 400.""" + # Use a subdirectory as work_dir so the external dir is truly outside + work_dir = tmp_path / "workspace" + work_dir.mkdir() + resp = client.post("/api/sessions/", json={"work_dir": str(work_dir)}) + assert resp.status_code == 200 + session_id = resp.json()["session_id"] + + # Create an external directory and a symlink inside work_dir pointing to it + external_dir = tmp_path / "external" + external_dir.mkdir() + (external_dir / "secret.txt").write_text("secret") + (work_dir / "linked").symlink_to(external_dir) + + # Also create a normal file to ensure the rest of the listing is unaffected + (work_dir / "normal.txt").write_text("ok") + + # Accessing the symlink directory should return an empty listing + resp = client.get(f"/api/sessions/{session_id}/files/linked") + assert resp.status_code == 200 + assert resp.json() == [] + + # The root listing should still include the symlink entry and the normal file + resp = client.get(f"/api/sessions/{session_id}/files/") + assert resp.status_code == 200 + names = [e["name"] for e in resp.json()] + assert "normal.txt" in names + assert "linked" in names diff --git a/web/src/App.tsx b/web/src/App.tsx index f387c6c57..5408b105b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -102,6 +102,10 @@ function App() { const [streamStatus, setStreamStatus] = useState("ready"); + // Track sessions with unread results (completed a turn while user wasn't viewing) + const [unreadSessionIds, setUnreadSessionIds] = useState>(new Set()); + const prevSessionStatusRef = useRef>(new Map()); + useEffect(() => { const token = consumeAuthTokenFromUrl(); if (token) { @@ -266,10 +270,48 @@ function App() { setStreamStatus(nextStatus); }, []); + // Detect busy→idle transitions from API refresh to mark sessions as unread + useEffect(() => { + const prev = prevSessionStatusRef.current; + const next = new Map(); + const newUnread: string[] = []; + + for (const session of sessions) { + const state = session.status?.state ?? null; + if (state) { + next.set(session.sessionId, state); + } + const prevState = prev.get(session.sessionId); + // Detect busy → non-busy (idle, stopped, null/gone) for non-selected sessions + if (prevState === "busy" && state !== "busy" && session.sessionId !== selectedSessionId) { + newUnread.push(session.sessionId); + } + } + + prevSessionStatusRef.current = next; + + if (newUnread.length > 0) { + setUnreadSessionIds((prev) => { + const updated = new Set(prev); + for (const id of newUnread) updated.add(id); + return updated; + }); + } + }, [sessions, selectedSessionId]); + const handleSessionStatus = useCallback( (status: SessionStatus) => { applySessionStatus(status); + // Mark as unread when a non-selected session finishes (becomes non-busy) + if (status.state !== "busy" && status.sessionId !== selectedSessionId) { + setUnreadSessionIds((prev) => { + const next = new Set(prev); + next.add(status.sessionId); + return next; + }); + } + if (status.state !== "idle") { return; } @@ -291,7 +333,7 @@ function App() { ); refreshSession(status.sessionId); }, - [applySessionStatus, refreshSession], + [applySessionStatus, refreshSession, selectedSessionId], ); const handleCreateSession = useCallback( @@ -319,6 +361,13 @@ function App() { (sessionId: string) => { selectSession(sessionId); setIsMobileSidebarOpen(false); + // Clear unread status when user views the session + setUnreadSessionIds((prev) => { + if (!prev.has(sessionId)) return prev; + const next = new Set(prev); + next.delete(sessionId); + return next; + }); }, [selectSession], ); @@ -343,8 +392,10 @@ function App() { updatedAt: formatRelativeTime(session.lastUpdated), workDir: session.workDir, lastUpdated: session.lastUpdated, + statusState: session.status?.state ?? null, + isUnread: unreadSessionIds.has(session.sessionId), })), - [sessions], + [sessions, unreadSessionIds], ); // Transform archived Session[] to SessionSummary[] for sidebar @@ -356,6 +407,8 @@ function App() { updatedAt: formatRelativeTime(session.lastUpdated), workDir: session.workDir, lastUpdated: session.lastUpdated, + statusState: session.status?.state ?? null, + isUnread: false, })), [archivedSessions], ); diff --git a/web/src/features/chat/useFileMentions.ts b/web/src/features/chat/useFileMentions.ts index 0e8e0b940..3f452727d 100644 --- a/web/src/features/chat/useFileMentions.ts +++ b/web/src/features/chat/useFileMentions.ts @@ -143,7 +143,13 @@ const crawlWorkspace = async ({ // "." should be treated as undefined for API root const path = current === "." ? undefined : current; - const entries = await listDirectory(sessionId, path); + let entries: SessionFileEntry[]; + try { + entries = await listDirectory(sessionId, path); + } catch { + // Skip directories that fail (e.g. symlinks outside work_dir) + continue; + } for (const entry of entries) { const fullPath = diff --git a/web/src/features/sessions/sessions.tsx b/web/src/features/sessions/sessions.tsx index 6b55d7f33..1f582c37a 100644 --- a/web/src/features/sessions/sessions.tsx +++ b/web/src/features/sessions/sessions.tsx @@ -53,7 +53,8 @@ import { CollapsibleContent, } from "@/components/ui/collapsible"; import { hasPlatformModifier, isMacOS } from "@/hooks/utils"; -import { cn, } from "@/lib/utils"; +import { cn } from "@/lib/utils"; +import { motion } from "motion/react"; // Top-level regex constants for performance const NEWLINE_REGEX = /\r\n|\r|\n/; @@ -65,6 +66,10 @@ type SessionSummary = { updatedAt: string; workDir?: string | null; lastUpdated: Date; + /** Runtime session state: busy, idle, stopped, etc. */ + statusState?: string | null; + /** Whether this session has unread results */ + isUnread?: boolean; }; type ViewMode = "list" | "grouped"; @@ -87,6 +92,38 @@ function shortenPath(path: string, maxLen = 30): string { return ".../" + parts.slice(-2).join("/"); } +/** + * Small dot indicator for session status, rendered after the timestamp. + * - busy: green dot with outward pulse animation (matches the bottom status indicator) + * - unread: static blue dot + * - idle/default: no dot + */ +function SessionStatusDot({ statusState, isUnread }: { statusState?: string | null; isUnread?: boolean }) { + if (statusState === "busy") { + return ( + + + + + ); + } + if (isUnread) { + return ; + } + return null; +} + type SessionsSidebarProps = { sessions: SessionSummary[]; archivedSessions?: SessionSummary[]; @@ -936,8 +973,9 @@ export const SessionsSidebar = memo(function SessionsSidebarComponent({ )} {!isEditing && ( - + {session.updatedAt} + )} @@ -1055,8 +1093,9 @@ export const SessionsSidebar = memo(function SessionsSidebarComponent({ {normalizeTitle(session.title)} - + {session.updatedAt} + )}