feat: v0.6.0 全量审计修复 — 安全加固 + 死代码激活 + 依赖治理
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m45s
CI / 全量测试 (Electron ABI) (push) Failing after 5m22s
CI / 产物编译验证 (push) Successful in 10m0s

基于全量源码审计(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:
2026-08-22 18:28:31 +08:00
parent 22028c91c3
commit 6b2b587c94
22 changed files with 723 additions and 504 deletions
+5 -1
View File
@@ -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(() => {
+7 -2
View File
@@ -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);
+2 -2
View File
@@ -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}
+153 -34
View File
@@ -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>
+34 -15
View File
@@ -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.mdMEMORY.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>