From 1865503c5929d4fadf0866fade9f113fe26e446c Mon Sep 17 00:00:00 2001 From: xiejin Date: Wed, 1 Apr 2026 12:40:19 +0800 Subject: [PATCH 1/2] fix(web): handle PermissionError and symlinks when listing directory entries When the work directory contains entries that the process cannot access (e.g. ~/.Trash on macOS due to TCC, or permission-restricted directories on Linux), iterdir() or stat() raises PermissionError, causing the get_session_file endpoint to return HTTP 500. Wrap iterdir() and per-entry stat/is_dir calls with try/except to gracefully skip inaccessible entries instead of crashing. Also fix symlinks pointing outside work_dir (e.g. in an Obsidian vault) that cause resolve() to fail the path-traversal check with HTTP 400. Return an empty directory listing instead of rejecting the request, so the frontend BFS can continue indexing the rest of the workspace. On the frontend, add try/catch around individual listDirectory calls in crawlWorkspace so a single directory failure does not abort the entire workspace file index for @ mentions. --- CHANGELOG.md | 2 + docs/en/release-notes/changelog.md | 2 + docs/zh/release-notes/changelog.md | 2 + src/kimi_cli/web/api/sessions.py | 35 +++++--- tests/web/test_sessions_file_listing.py | 106 +++++++++++++++++++++++ web/src/features/chat/useFileMentions.ts | 8 +- 6 files changed, 142 insertions(+), 13 deletions(-) create mode 100644 tests/web/test_sessions_file_listing.py 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/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 = From dacfe81d9baad37b23ec0de2d23b9fbfdb95a7db Mon Sep 17 00:00:00 2001 From: xiejin Date: Wed, 1 Apr 2026 19:24:14 +0800 Subject: [PATCH 2/2] feat(web): show session status indicators in sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add visual dot indicators after the timestamp in the session list: - busy: green pulsing dot (animate-pulse) - unread: blue static dot (completed while user was viewing another session) - idle: no dot (default) Track unread sessions via busy→idle transitions from both WebSocket status events and API refresh polling. Clear unread on session select. --- web/src/App.tsx | 57 +++++++++++++++++++++++++- web/src/features/sessions/sessions.tsx | 45 ++++++++++++++++++-- 2 files changed, 97 insertions(+), 5 deletions(-) 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/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} + )}