feat: add session deletion with per-provider cleanup and path safety

Add delete_session Tauri command dispatching to provider-specific deletion
logic for all 5 providers (Claude, Codex, Gemini, OpenCode, OpenClaw).
Includes path traversal protection via canonicalize + starts_with validation,
session ID verification against file contents, frontend confirmation dialog
with optimistic cache updates, i18n keys (zh/en/ja), and component tests.
This commit is contained in:
Jason
2026-03-06 23:09:38 +08:00
parent e18db31752
commit 8c3f18a9bd
17 changed files with 1043 additions and 15 deletions

View File

@@ -0,0 +1,140 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SessionManagerPage } from "@/components/sessions/SessionManagerPage";
import type { SessionMessage, SessionMeta } from "@/types";
import { setSessionFixtures } from "../msw/state";
const toastSuccessMock = vi.fn();
const toastErrorMock = vi.fn();
vi.mock("sonner", () => ({
toast: {
success: (...args: unknown[]) => toastSuccessMock(...args),
error: (...args: unknown[]) => toastErrorMock(...args),
},
}));
vi.mock("@/components/sessions/SessionToc", () => ({
SessionTocSidebar: () => null,
SessionTocDialog: () => null,
}));
vi.mock("@/components/ConfirmDialog", () => ({
ConfirmDialog: ({
isOpen,
title,
message,
confirmText,
cancelText,
onConfirm,
onCancel,
}: {
isOpen: boolean;
title: string;
message: string;
confirmText: string;
cancelText: string;
onConfirm: () => void;
onCancel: () => void;
}) =>
isOpen ? (
<div data-testid="confirm-dialog">
<div>{title}</div>
<div>{message}</div>
<button onClick={onConfirm}>{confirmText}</button>
<button onClick={onCancel}>{cancelText}</button>
</div>
) : null,
}));
const renderPage = () => {
const client = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={client}>
<SessionManagerPage appId="codex" />
</QueryClientProvider>,
);
};
describe("SessionManagerPage", () => {
beforeEach(() => {
toastSuccessMock.mockReset();
toastErrorMock.mockReset();
const sessions: SessionMeta[] = [
{
providerId: "codex",
sessionId: "codex-session-1",
title: "Alpha Session",
summary: "Alpha summary",
projectDir: "/mock/codex",
createdAt: 2,
lastActiveAt: 20,
sourcePath: "/mock/codex/session-1.jsonl",
resumeCommand: "codex resume codex-session-1",
},
{
providerId: "codex",
sessionId: "codex-session-2",
title: "Beta Session",
summary: "Beta summary",
projectDir: "/mock/codex",
createdAt: 1,
lastActiveAt: 10,
sourcePath: "/mock/codex/session-2.jsonl",
resumeCommand: "codex resume codex-session-2",
},
];
const messages: Record<string, SessionMessage[]> = {
"codex:/mock/codex/session-1.jsonl": [
{ role: "user", content: "alpha", ts: 20 },
],
"codex:/mock/codex/session-2.jsonl": [
{ role: "user", content: "beta", ts: 10 },
],
};
setSessionFixtures(sessions, messages);
});
it("deletes the selected session and selects the next visible session", async () => {
renderPage();
await waitFor(() =>
expect(
screen.getByRole("heading", { name: "Alpha Session" }),
).toBeInTheDocument(),
);
fireEvent.click(screen.getByRole("button", { name: /删除会话/i }));
const dialog = screen.getByTestId("confirm-dialog");
expect(dialog).toBeInTheDocument();
expect(within(dialog).getByText(/Alpha Session/)).toBeInTheDocument();
fireEvent.click(within(dialog).getByRole("button", { name: /删除会话/i }));
await waitFor(() =>
expect(
screen.getByRole("heading", { name: "Beta Session" }),
).toBeInTheDocument(),
);
expect(screen.queryByText("Alpha Session")).not.toBeInTheDocument();
expect(toastErrorMock).not.toHaveBeenCalled();
expect(toastSuccessMock).toHaveBeenCalled();
});
});

View File

@@ -4,9 +4,12 @@ import type { McpServer, Provider, Settings } from "@/types";
import {
addProvider,
deleteProvider,
deleteSession,
getCurrentProviderId,
getSessionMessages,
getProviders,
listProviders,
listSessions,
resetProviderState,
setCurrentProviderId,
updateProvider,
@@ -37,7 +40,9 @@ const success = <T>(payload: T) => HttpResponse.json(payload as any);
export const handlers = [
http.post(`${TAURI_ENDPOINT}/get_migration_result`, () => success(false)),
http.post(`${TAURI_ENDPOINT}/get_skills_migration_result`, () => success(null)),
http.post(`${TAURI_ENDPOINT}/get_skills_migration_result`, () =>
success(null),
),
http.post(`${TAURI_ENDPOINT}/get_providers`, async ({ request }) => {
const { app } = await withJson<{ app: AppId }>(request);
return success(getProviders(app));
@@ -105,6 +110,25 @@ export const handlers = [
http.post(`${TAURI_ENDPOINT}/open_external`, () => success(true)),
http.post(`${TAURI_ENDPOINT}/list_sessions`, () => success(listSessions())),
http.post(`${TAURI_ENDPOINT}/get_session_messages`, async ({ request }) => {
const { providerId, sourcePath } = await withJson<{
providerId: string;
sourcePath: string;
}>(request);
return success(getSessionMessages(providerId, sourcePath));
}),
http.post(`${TAURI_ENDPOINT}/delete_session`, async ({ request }) => {
const { providerId, sessionId, sourcePath } = await withJson<{
providerId: string;
sessionId: string;
sourcePath: string;
}>(request);
return success(deleteSession(providerId, sessionId, sourcePath));
}),
// MCP APIs
http.post(`${TAURI_ENDPOINT}/get_mcp_config`, async ({ request }) => {
const { app } = await withJson<{ app: AppId }>(request);
@@ -178,9 +202,13 @@ export const handlers = [
},
),
http.post(`${TAURI_ENDPOINT}/apply_claude_onboarding_skip`, () => success(true)),
http.post(`${TAURI_ENDPOINT}/apply_claude_onboarding_skip`, () =>
success(true),
),
http.post(`${TAURI_ENDPOINT}/clear_claude_onboarding_skip`, () => success(true)),
http.post(`${TAURI_ENDPOINT}/clear_claude_onboarding_skip`, () =>
success(true),
),
http.post(`${TAURI_ENDPOINT}/get_config_dir`, async ({ request }) => {
const { app } = await withJson<{ app: AppId }>(request);
@@ -280,7 +308,9 @@ export const handlers = [
success([]),
),
http.post(`${TAURI_ENDPOINT}/add_to_failover_queue`, () => success(true)),
http.post(`${TAURI_ENDPOINT}/remove_from_failover_queue`, () => success(true)),
http.post(`${TAURI_ENDPOINT}/remove_from_failover_queue`, () =>
success(true),
),
http.post(`${TAURI_ENDPOINT}/reorder_failover_queue`, () => success(true)),
http.post(`${TAURI_ENDPOINT}/set_failover_item_enabled`, () => success(true)),

View File

@@ -1,5 +1,11 @@
import type { AppId } from "@/lib/api/types";
import type { McpServer, Provider, Settings } from "@/types";
import type {
McpServer,
Provider,
SessionMessage,
SessionMeta,
Settings,
} from "@/types";
type ProvidersByApp = Record<AppId, Record<string, Provider>>;
type CurrentProviderState = Record<AppId, string>;
@@ -80,13 +86,69 @@ let settingsState: Settings = {
language: "zh",
};
let appConfigDirOverride: string | null = null;
const sessionMessageKey = (providerId: string, sourcePath: string) =>
`${providerId}:${sourcePath}`;
const createDefaultSessions = (): SessionMeta[] => {
const now = Date.now();
return [
{
providerId: "codex",
sessionId: "codex-session-1",
title: "Codex Session One",
summary: "Codex summary",
projectDir: "/mock/codex",
createdAt: now - 2000,
lastActiveAt: now - 1000,
sourcePath: "/mock/codex/session-1.jsonl",
resumeCommand: "codex resume codex-session-1",
},
{
providerId: "claude",
sessionId: "claude-session-1",
title: "Claude Session One",
summary: "Claude summary",
projectDir: "/mock/claude",
createdAt: now - 4000,
lastActiveAt: now - 3000,
sourcePath: "/mock/claude/session-1.jsonl",
resumeCommand: "claude --resume claude-session-1",
},
];
};
const createDefaultSessionMessages = (): Record<string, SessionMessage[]> => ({
[sessionMessageKey("codex", "/mock/codex/session-1.jsonl")]: [
{
role: "user",
content: "First codex message",
ts: Date.now() - 1000,
},
],
[sessionMessageKey("claude", "/mock/claude/session-1.jsonl")]: [
{
role: "user",
content: "First claude message",
ts: Date.now() - 3000,
},
],
});
let sessionsState = createDefaultSessions();
let sessionMessagesState = createDefaultSessionMessages();
let mcpConfigs: McpConfigState = {
claude: {
sample: {
id: "sample",
name: "Sample Claude Server",
enabled: true,
apps: { claude: true, codex: false, gemini: false, opencode: false, openclaw: false },
apps: {
claude: true,
codex: false,
gemini: false,
opencode: false,
openclaw: false,
},
server: {
type: "stdio",
command: "claude-server",
@@ -98,7 +160,13 @@ let mcpConfigs: McpConfigState = {
id: "httpServer",
name: "HTTP Codex Server",
enabled: false,
apps: { claude: false, codex: true, gemini: false, opencode: false, openclaw: false },
apps: {
claude: false,
codex: true,
gemini: false,
opencode: false,
openclaw: false,
},
server: {
type: "http",
url: "http://localhost:3000",
@@ -116,6 +184,8 @@ const cloneProviders = (value: ProvidersByApp) =>
export const resetProviderState = () => {
providers = createDefaultProviders();
current = createDefaultCurrent();
sessionsState = createDefaultSessions();
sessionMessagesState = createDefaultSessionMessages();
settingsState = {
showInTray: true,
minimizeToTrayOnClose: true,
@@ -131,7 +201,13 @@ export const resetProviderState = () => {
id: "sample",
name: "Sample Claude Server",
enabled: true,
apps: { claude: true, codex: false, gemini: false, opencode: false, openclaw: false },
apps: {
claude: true,
codex: false,
gemini: false,
opencode: false,
openclaw: false,
},
server: {
type: "stdio",
command: "claude-server",
@@ -143,7 +219,13 @@ export const resetProviderState = () => {
id: "httpServer",
name: "HTTP Codex Server",
enabled: false,
apps: { claude: false, codex: true, gemini: false, opencode: false, openclaw: false },
apps: {
claude: false,
codex: true,
gemini: false,
opencode: false,
openclaw: false,
},
server: {
type: "http",
url: "http://localhost:3000",
@@ -285,3 +367,41 @@ export const deleteMcpServer = (appType: AppId, id: string) => {
if (!mcpConfigs[appType]) return;
delete mcpConfigs[appType][id];
};
export const listSessions = () =>
JSON.parse(JSON.stringify(sessionsState)) as SessionMeta[];
export const getSessionMessages = (providerId: string, sourcePath: string) =>
JSON.parse(
JSON.stringify(
sessionMessagesState[sessionMessageKey(providerId, sourcePath)] ?? [],
),
) as SessionMessage[];
export const deleteSession = (
providerId: string,
sessionId: string,
sourcePath: string,
) => {
sessionsState = sessionsState.filter(
(session) =>
!(
session.providerId === providerId &&
session.sessionId === sessionId &&
session.sourcePath === sourcePath
),
);
delete sessionMessagesState[sessionMessageKey(providerId, sourcePath)];
return true;
};
export const setSessionFixtures = (
sessions: SessionMeta[],
messages: Record<string, SessionMessage[]>,
) => {
sessionsState = JSON.parse(JSON.stringify(sessions)) as SessionMeta[];
sessionMessagesState = JSON.parse(JSON.stringify(messages)) as Record<
string,
SessionMessage[]
>;
};