feat: v0.6.0 全量审计修复 — 安全加固 + 死代码激活 + 依赖治理
基于全量源码审计(electron/ 70+ 文件、src/ 45+ 文件完整读取)的 四阶段迭代,修复 1 项安全缺陷、8 项功能缺陷、依赖与文档系统性脱节。 【P0 安全与数据正确性】 - S-1 数据导出泄露明文密钥:data:export 全量导出直接透传 configService.getAll()(敏感 key 解密返回),导出文件含明文 API Key。 新增 sanitizeExportConfig(shared.ts)逐 key 脱敏 + 5 项回归测试 - F-3 session_summaries 无级联删除:删会话后摘要永久残留。建表语句补 FOREIGN KEY ON DELETE CASCADE + 迁移 8 重建存量表(幂等检测)+ 2 项测试 - F-4 macOS activate 重建窗口后确认弹框失效:窗口创建收敛为 createMainWindow 单一入口(beforeLoad 补 setMainWindow/IPC 注册),TrayManager 补 rebindWindow,app:selectFolder 改 event.sender 动态解析窗口 【P1 死代码激活与功能补全】 - F-1 会话右键菜单挂载:ContextMenu session 分支(重命名/置顶/归档/ 导出/删除)约 200 行此前无任何触发点,Sidebar SessionItem 挂载 onContextMenu;置顶/归档 label 随状态切换显示 - F-2 归档会话不可找回:Sidebar 新增「已归档」折叠面板(恢复入口), 归档功能形成完整闭环 - F-7 Provider 白名单校验:createAdapter/buildFallbackAdapter 未知 provider 显式拒绝(此前静默落入 DeepSeekAdapter 以空配置失败) - F-8 死配置治理:接通 5 项(llm.temperature/llm.maxTokens 注入引擎、 security.promptInjectionDefense 控制 SecurityScanHook+消息检测、 logging.auditEnabled 控制 AuditLogHook、logging.traceEnabled 控制 SessionRecorder,均 fail-secure 仅显式 false 关闭);删除 4 项 无消费者配置(requireWriteConfirmation/maxFileWriteSizeKB/fontSize/ animationMode) 【P2 依赖治理与 UX 修缮】 - D-1 移除僵尸依赖 electron-store/zod/rehype-raw(源码零引用, 共裁 24 包);README 技术栈表同步删除虚假宣称 - metona-toast 升级 0.2.1 → 0.5.0(API 全兼容:107 种图标类型、 配置项超集,default/configure/use 接口不变) - F-5 工作空间手输路径实时落库中间态:改 pendingPath 草稿 + 显式 「校验」按钮(选择文件夹与手输共用 validatePath 流程) - F-6 确认弹框超时滞留:倒计时归零时主动 refreshPending(后端超时 已删条目,拉取后弹框自然消解) - D-3 ChatInput accept 移除 .pdf(分类器不识别,误导性入口); Onboarding 切换 Provider 自动填充默认 URL(与 LLMSettings 一致) 【P3 文档口径收敛】 - README:工具数 30+→28、版本 0.6.0、测试数 252、配置表补 F-8 接通项 - built-in/index.ts 计数注释 30→28 - docs/网络工具 v2 存储键名统一为点号口径(searxng.enabled) - docs/完整设计指南修正 db.handlers.ts 失效路径引用为 data.ts 【验证】 - lint 0 error / 0 warning - typecheck 双工程(node+web)0 错误 - test:electron 24 文件 252 用例全通过(+7 新增:导出脱敏 ×5、 级联删除 ×2) - electron-vite build 成功(metona-toast 0.5.0 chunk 正常) - 系统 Node 模式 npm test 225 通过 + 27 ABI skip(符合预期)
This commit is contained in:
@@ -199,6 +199,9 @@ export function ConfirmationDialog(): React.JSX.Element | null {
|
||||
}, []);
|
||||
|
||||
// 倒计时:每 200ms 更新剩余时间(取所有请求中最早过期的)
|
||||
// F-6 修复: 归零时主动拉取 pending —— 后端超时已 resolve(false) 并删除条目,
|
||||
// 但此前不通知前端,弹框滞留在"已超时"且禁止关闭。主动拉取后列表为空,
|
||||
// 弹框自然消解;若后端 timer 尚未触发(前端轮询略早),拉回的 pending 保留原请求。
|
||||
useEffect(() => {
|
||||
if (requests.length === 0) return;
|
||||
const interval = setInterval(() => {
|
||||
@@ -215,12 +218,13 @@ export function ConfirmationDialog(): React.JSX.Element | null {
|
||||
if (remaining <= 0) {
|
||||
setRemainingMs(0);
|
||||
clearInterval(interval);
|
||||
void refreshPending();
|
||||
} else {
|
||||
setRemainingMs(remaining);
|
||||
}
|
||||
}, 200);
|
||||
return () => clearInterval(interval);
|
||||
}, [requests]);
|
||||
}, [requests, refreshPending]);
|
||||
|
||||
// 按工具名分组(同工具多次调用折叠为一组)
|
||||
const grouped: GroupedRequests[] = useMemo(() => {
|
||||
|
||||
@@ -367,7 +367,9 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
|
||||
{
|
||||
id: 'pin',
|
||||
icon: Pin,
|
||||
label: '置顶',
|
||||
label: useSessionStore.getState().sessions.find((x) => x.id === sid)?.pinned
|
||||
? '取消置顶'
|
||||
: '置顶',
|
||||
action: async () => {
|
||||
if (!sid) return;
|
||||
const s = useSessionStore.getState().sessions.find((x) => x.id === sid);
|
||||
@@ -387,10 +389,13 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
|
||||
},
|
||||
},
|
||||
// 修复: archive 之前完全未调用 IPC,UI 改了但 DB 没改,重启后状态丢失
|
||||
// F-2: 归档后可在侧栏「已归档」折叠面板中找回
|
||||
{
|
||||
id: 'archive',
|
||||
icon: Archive,
|
||||
label: '归档',
|
||||
label: useSessionStore.getState().sessions.find((x) => x.id === sid)?.archived
|
||||
? '取消归档'
|
||||
: '归档',
|
||||
action: async () => {
|
||||
if (!sid) return;
|
||||
const s = useSessionStore.getState().sessions.find((x) => x.id === sid);
|
||||
|
||||
@@ -505,8 +505,8 @@ export function ChatInput(): React.JSX.Element {
|
||||
multiple
|
||||
accept={
|
||||
supportsImages
|
||||
? 'image/*,.txt,.md,.json,.csv,.ts,.tsx,.js,.jsx,.py,.rb,.go,.rs,.java,.c,.cpp,.h,.css,.html,.xml,.yaml,.yml,.toml,.ini,.sh,.sql,.log,.pdf'
|
||||
: '.txt,.md,.json,.csv,.ts,.tsx,.js,.jsx,.py,.rb,.go,.rs,.java,.c,.cpp,.h,.css,.html,.xml,.yaml,.yml,.toml,.ini,.sh,.sql,.log,.pdf'
|
||||
? 'image/*,.txt,.md,.json,.csv,.ts,.tsx,.js,.jsx,.py,.rb,.go,.rs,.java,.c,.cpp,.h,.css,.html,.xml,.yaml,.yml,.toml,.ini,.sh,.sql,.log'
|
||||
: '.txt,.md,.json,.csv,.ts,.tsx,.js,.jsx,.py,.rb,.go,.rs,.java,.c,.cpp,.h,.css,.html,.xml,.yaml,.yml,.toml,.ini,.sh,.sql,.log'
|
||||
}
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
|
||||
@@ -33,11 +33,13 @@ import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Trash2,
|
||||
Archive,
|
||||
} from 'lucide-react';
|
||||
import { useSessionStore, type Session } from '@renderer/stores/session-store';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
import { formatRelativeTime } from '@renderer/lib/formatters';
|
||||
import { LAYOUT } from '@renderer/lib/constants';
|
||||
import { ContextMenu, createContextMenuItems } from '@renderer/components/ContextMenu';
|
||||
|
||||
export function Sidebar(): React.JSX.Element {
|
||||
const sessions = useSessionStore((s) => s.sessions);
|
||||
@@ -60,29 +62,27 @@ export function Sidebar(): React.JSX.Element {
|
||||
.list()
|
||||
.then((list) => {
|
||||
if (cancelled) return;
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setSessions(
|
||||
(
|
||||
list as Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
messageCount: number;
|
||||
pinned: boolean;
|
||||
archived: boolean;
|
||||
}>
|
||||
).map((s) => ({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
createdAt: s.createdAt,
|
||||
updatedAt: s.updatedAt,
|
||||
messageCount: s.messageCount,
|
||||
pinned: s.pinned,
|
||||
archived: s.archived,
|
||||
})),
|
||||
);
|
||||
useSessionStore.getState().setSessions(
|
||||
(
|
||||
list as Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
messageCount: number;
|
||||
pinned: boolean;
|
||||
archived: boolean;
|
||||
}>
|
||||
).map((s) => ({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
createdAt: s.createdAt,
|
||||
updatedAt: s.updatedAt,
|
||||
messageCount: s.messageCount,
|
||||
pinned: s.pinned,
|
||||
archived: s.archived,
|
||||
})),
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
// M-11 修复: 记录错误而非静默吞掉,便于诊断
|
||||
@@ -155,17 +155,15 @@ export function Sidebar(): React.JSX.Element {
|
||||
pinned: boolean;
|
||||
archived: boolean;
|
||||
};
|
||||
useSessionStore
|
||||
.getState()
|
||||
.addSession({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
createdAt: r.createdAt,
|
||||
updatedAt: r.updatedAt,
|
||||
messageCount: r.messageCount,
|
||||
pinned: r.pinned,
|
||||
archived: r.archived,
|
||||
});
|
||||
useSessionStore.getState().addSession({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
createdAt: r.createdAt,
|
||||
updatedAt: r.updatedAt,
|
||||
messageCount: r.messageCount,
|
||||
pinned: r.pinned,
|
||||
archived: r.archived,
|
||||
});
|
||||
setCurrentSession(r.id);
|
||||
loadSessionMessages(r.id);
|
||||
return;
|
||||
@@ -265,12 +263,116 @@ export function Sidebar(): React.JSX.Element {
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mt: 'auto', mb: 1 }} />
|
||||
{/* F-2 修复: 归档会话视图 —— 此前归档入口激活后(会话右键菜单),
|
||||
归档会话从列表永久消失且无恢复路径(数据在 DB 但 UI 不可达) */}
|
||||
<ArchivedSessionsPanel />
|
||||
<ToolManagerPanel />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* F-2: 已归档会话折叠面板 — 提供「恢复到列表」入口
|
||||
*
|
||||
* 归档会话不参与主列表渲染(filteredSessions 过滤 archived),
|
||||
* 此面板是唯一的找回路径:恢复(archive IPC 反向调用)或删除。
|
||||
*/
|
||||
function ArchivedSessionsPanel(): React.JSX.Element | null {
|
||||
const sessions = useSessionStore((s) => s.sessions);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const archived = sessions.filter((s) => s.archived);
|
||||
|
||||
if (archived.length === 0) return null;
|
||||
|
||||
const handleUnarchive = async (sessionId: string) => {
|
||||
try {
|
||||
const r = await window.metona?.sessions?.archive(sessionId, false);
|
||||
if (r?.success) {
|
||||
useSessionStore.getState().archiveSession(sessionId, false);
|
||||
} else {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(r?.error ?? '恢复失败'))
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Sidebar] Unarchive failed:', err);
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error('恢复失败'))
|
||||
.catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<ListItemButton
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
dense
|
||||
sx={{ borderRadius: 1, px: 1.5, py: 0.75 }}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 24 }}>
|
||||
{expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
<Archive size={12} style={{ marginLeft: 4 }} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Typography variant="body2" sx={{ fontSize: 12 }}>
|
||||
已归档
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 10 }}>
|
||||
{archived.length} 个
|
||||
</Typography>
|
||||
</ListItemButton>
|
||||
<Collapse in={expanded}>
|
||||
<List
|
||||
dense
|
||||
disablePadding
|
||||
sx={{
|
||||
pl: 2,
|
||||
maxHeight: 180,
|
||||
overflowY: 'auto',
|
||||
pr: 0.5,
|
||||
'&::-webkit-scrollbar': { width: 6 },
|
||||
'&::-webkit-scrollbar-thumb': { bgcolor: 'divider', borderRadius: 3 },
|
||||
}}
|
||||
>
|
||||
{archived.map((s) => (
|
||||
<ListItemButton key={s.id} dense sx={{ py: 0.25, px: 1, borderRadius: 1 }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
fontSize: 11,
|
||||
color: 'text.disabled',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
title={s.title}
|
||||
>
|
||||
{s.title}
|
||||
</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
sx={{ fontSize: 10, minWidth: 40, flexShrink: 0 }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void handleUnarchive(s.id);
|
||||
}}
|
||||
>
|
||||
恢复
|
||||
</Button>
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionItem({
|
||||
session,
|
||||
isActive,
|
||||
@@ -285,6 +387,9 @@ function SessionItem({
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
// F-1 修复: 挂载会话右键菜单(重命名/置顶/归档/导出/删除)
|
||||
// 此前 ContextMenu 的 session 分支约 200 行无任何触发点(死代码)
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
const handleDelete = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -337,6 +442,10 @@ function SessionItem({
|
||||
onClick={onClick}
|
||||
selected={isActive}
|
||||
dense
|
||||
onContextMenu={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY });
|
||||
}}
|
||||
sx={{
|
||||
borderRadius: 1.5,
|
||||
mb: 0.25,
|
||||
@@ -447,6 +556,16 @@ function SessionItem({
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* F-1: 会话右键菜单(重命名/置顶/归档/导出/删除),此前无触发点 */}
|
||||
{contextMenu && (
|
||||
<ContextMenu
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
items={createContextMenuItems('session', { sessionId: session.id })}
|
||||
onClose={() => setContextMenu(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import { ArrowRight, ArrowLeft, CheckCircle, Eye, EyeOff } from 'lucide-react';
|
||||
import { useUIStore } from '@renderer/stores/ui-store';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
import { PROVIDER_URLS } from '@renderer/components/settings/useConfig';
|
||||
|
||||
const STEPS = ['欢迎', '配置 LLM', '自定义 Agent', '工作空间', '开始使用'];
|
||||
|
||||
@@ -245,6 +246,13 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
||||
setProvider(v);
|
||||
// 联动默认上下文窗口(与 SettingsModal 默认值一致)
|
||||
setContextWindow(DEFAULT_CTX[v] ?? null);
|
||||
// D-3 修复: 自动填充默认 URL(与 LLMSettings 行为一致 —
|
||||
// 仅在 URL 为空或仍是某 Provider 的默认值时覆盖,用户自定义 URL 不动)
|
||||
const currentUrl = baseURL.trim();
|
||||
const isDefaultUrl = Object.values(PROVIDER_URLS).includes(currentUrl);
|
||||
if (isDefaultUrl || !currentUrl) {
|
||||
setBaseURL(PROVIDER_URLS[v] ?? '');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuItem value="deepseek">DeepSeek</MenuItem>
|
||||
|
||||
@@ -36,23 +36,33 @@ export function WorkspaceSettings() {
|
||||
|
||||
const currentPath = workspacePath;
|
||||
|
||||
/**
|
||||
* 校验路径并进入"待切换"状态(选择文件夹与手输路径共用)
|
||||
* F-5 修复: 手输路径此前经 useConfig 实时落库 —— 每敲一个字符就持久化
|
||||
* 残缺路径并写 workspace-config.json,中途退出后下次启动加载半截路径。
|
||||
* 现在输入只更新 pendingPath 草稿,点「校验」后才进入切换流程。
|
||||
*/
|
||||
const validatePath = async (path: string) => {
|
||||
if (!path.trim()) return;
|
||||
setPendingPath(path.trim());
|
||||
setChecking(true);
|
||||
setCheckResult(null);
|
||||
try {
|
||||
const result = await window.metona.workspace.check(path.trim());
|
||||
setCheckResult(result);
|
||||
} catch (err) {
|
||||
console.error('[WorkspaceSettings]', err);
|
||||
setCheckResult({ valid: false, reason: (err as Error).message });
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = async () => {
|
||||
if (!window.metona?.app?.selectFolder) return;
|
||||
const r = await window.metona.app.selectFolder(currentPath || undefined);
|
||||
if (!r.canceled && r.path) {
|
||||
// 立即校验新路径
|
||||
setPendingPath(r.path);
|
||||
setChecking(true);
|
||||
setCheckResult(null);
|
||||
try {
|
||||
const result = await window.metona.workspace.check(r.path);
|
||||
setCheckResult(result);
|
||||
} catch (err) {
|
||||
console.error('[WorkspaceSettings]', err);
|
||||
setCheckResult({ valid: false, reason: (err as Error).message });
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
await validatePath(r.path);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -168,13 +178,22 @@ export function WorkspaceSettings() {
|
||||
工作空间是 Metona 的组织核心,包含 SOUL.md、MEMORY.md 两个必需文件。
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
{/* F-5: 手输路径只更新草稿(pendingPath),不实时落库 */}
|
||||
<TextField
|
||||
size="small"
|
||||
value={workspacePath}
|
||||
onChange={(e) => setWorkspacePath(e.target.value)}
|
||||
value={pendingPath ?? workspacePath}
|
||||
onChange={(e) => setPendingPath(e.target.value)}
|
||||
placeholder="~/MetonaWorkspaces/default/"
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => validatePath(pendingPath ?? workspacePath)}
|
||||
disabled={checking || !(pendingPath ?? workspacePath).trim()}
|
||||
>
|
||||
{checking ? '校验中...' : '校验'}
|
||||
</Button>
|
||||
<Button variant="outlined" size="small" onClick={handleSelect}>
|
||||
选择文件夹
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user