Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/en/release-notes/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Manually edited auto-generated English changelog violating repo rules

docs/en/release-notes/changelog.md is explicitly documented as auto-generated from the root CHANGELOG.md via the sync script (docs/scripts/sync-changelog.mjs). The docs/AGENTS.md:185 rule states: "The English changelog (docs/en/release-notes/changelog.md) is auto-generated from the root CHANGELOG.md. Do not edit it manually." This PR manually edits the file, which will be overwritten the next time the sync script runs (during npm run dev or npm run build). The root CHANGELOG.md is already updated with the same entry, so the correct workflow is to only edit the root file and let the sync script propagate the change.

Prompt for agents
Remove the manual edit to docs/en/release-notes/changelog.md (line 7). This file is auto-generated from the root CHANGELOG.md by the sync script at docs/scripts/sync-changelog.mjs. Instead, only edit the root CHANGELOG.md (which is already done in this PR) and run `npm run sync` from the docs/ directory to regenerate the English changelog, or let it auto-sync during the next `npm run dev` / `npm run build`.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


## 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
Expand Down
2 changes: 2 additions & 0 deletions docs/zh/release-notes/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 预算上限下,更深层目录的文件优先保留,确保最具体的指令不会被截断
Expand Down
35 changes: 23 additions & 12 deletions src/kimi_cli/web/api/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Comment on lines +490 to +491
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return file error for out-of-root symlink targets

This unconditional early return treats any symlink escaping work_dir as a directory listing response, including symlinks to files. As a result, requesting a symlinked file outside the workspace now returns 200 with [] instead of a file-not-found/traversal error, which breaks file-fetch semantics and contradicts the function comment that files should 404.

Useful? React with 👍 / 👎.

Comment on lines +490 to +491
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Symlink to file outside work_dir returns [] instead of 404 as documented

The comment on lines 486-488 states: "For directories we return an empty listing so the frontend BFS can continue; for files we 404." However, the implementation at line 490-491 unconditionally returns Response(content="[]", media_type="application/json") for any symlink pointing outside work_dir, regardless of whether it targets a file or a directory. When a symlink to an external file is accessed, the endpoint returns a 200 with an empty JSON array instead of a 404, which violates the stated contract and would confuse the frontend (it would interpret a file path as an empty directory).

Suggested change
if requested_path.is_symlink():
return Response(content="[]", media_type="application/json")
if requested_path.is_symlink():
if requested_path.is_dir():
return Response(content="[]", media_type="application/json")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found",
)
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid path: path traversal not allowed",
Expand Down Expand Up @@ -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")

Expand Down
106 changes: 106 additions & 0 deletions tests/web/test_sessions_file_listing.py
Original file line number Diff line number Diff line change
@@ -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
57 changes: 55 additions & 2 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ function App() {

const [streamStatus, setStreamStatus] = useState<ChatStatus>("ready");

// Track sessions with unread results (completed a turn while user wasn't viewing)
const [unreadSessionIds, setUnreadSessionIds] = useState<Set<string>>(new Set());
const prevSessionStatusRef = useRef<Map<string, string>>(new Map());

useEffect(() => {
const token = consumeAuthTokenFromUrl();
if (token) {
Expand Down Expand Up @@ -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<string, string>();
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;
}
Expand All @@ -291,7 +333,7 @@ function App() {
);
refreshSession(status.sessionId);
},
[applySessionStatus, refreshSession],
[applySessionStatus, refreshSession, selectedSessionId],
);

const handleCreateSession = useCallback(
Expand Down Expand Up @@ -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],
);
Expand All @@ -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
Expand All @@ -356,6 +407,8 @@ function App() {
updatedAt: formatRelativeTime(session.lastUpdated),
workDir: session.workDir,
lastUpdated: session.lastUpdated,
statusState: session.status?.state ?? null,
isUnread: false,
})),
[archivedSessions],
);
Expand Down
8 changes: 7 additions & 1 deletion web/src/features/chat/useFileMentions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +147 to +151
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Propagate crawl errors instead of silently continuing

Catching every listDirectory failure inside crawlWorkspace and continuing means even top-level failures (e.g. auth expiry, backend 5xx, network errors) are converted into an empty/partial file list, so loadWorkspaceFiles marks the workspace as ready instead of error. This regresses mention UX because users lose the error state and retry path while suggestions silently disappear.

Useful? React with 👍 / 👎.

}

for (const entry of entries) {
const fullPath =
Expand Down
45 changes: 42 additions & 3 deletions web/src/features/sessions/sessions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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/;
Expand All @@ -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";
Expand All @@ -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 (
<span className="relative inline-flex size-3 shrink-0 items-center justify-center" aria-label="Running">
<motion.span
className="absolute size-2.5 rounded-full bg-green-500/50"
animate={{
scale: [1, 1.8, 1],
opacity: [0.6, 0, 0.6],
}}
transition={{
duration: 1.5,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
}}
/>
<span className="relative inline-block size-1.5 rounded-full bg-green-500" />
</span>
);
}
if (isUnread) {
return <span className="inline-block size-1.5 shrink-0 rounded-full bg-blue-500" aria-label="Unread" />;
}
return null;
}

type SessionsSidebarProps = {
sessions: SessionSummary[];
archivedSessions?: SessionSummary[];
Expand Down Expand Up @@ -936,8 +973,9 @@ export const SessionsSidebar = memo(function SessionsSidebarComponent({
</Tooltip>
)}
{!isEditing && (
<span className="text-[10px] text-muted-foreground mt-1 block">
<span className="inline-flex items-center gap-1.5 text-[10px] text-muted-foreground mt-1">
{session.updatedAt}
<SessionStatusDot statusState={session.statusState} isUnread={session.isUnread} />
</span>
)}
</button>
Expand Down Expand Up @@ -1055,8 +1093,9 @@ export const SessionsSidebar = memo(function SessionsSidebarComponent({
{normalizeTitle(session.title)}
</TooltipContent>
</Tooltip>
<span className="text-[10px] text-muted-foreground shrink-0">
<span className="inline-flex items-center gap-1.5 text-[10px] text-muted-foreground shrink-0">
{session.updatedAt}
<SessionStatusDot statusState={session.statusState} isUnread={session.isUnread} />
</span>
</div>
)}
Expand Down