From c6e077ee2de091ea00da911ff5811f49ee6eeb55 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Sun, 22 Mar 2026 13:34:29 +0800 Subject: [PATCH] docs: add secure persistence architecture and release pack --- .../secure-local-persistence-architecture.md | 211 ++++++++++++++++++ .../secure-local-persistence-postmortem.md | 181 +++++++++++++++ ...03-22-secure-persistence-release-update.md | 117 ++++++++++ ...26-03-22-secure-persistence-social-copy.md | 83 +++++++ 4 files changed, 592 insertions(+) create mode 100644 docs/architecture/secure-local-persistence-architecture.md create mode 100644 docs/cases/secure-local-persistence-postmortem.md create mode 100644 docs/releases/2026-03-22-secure-persistence-release-update.md create mode 100644 docs/releases/2026-03-22-secure-persistence-social-copy.md diff --git a/docs/architecture/secure-local-persistence-architecture.md b/docs/architecture/secure-local-persistence-architecture.md new file mode 100644 index 00000000..e6cb1da0 --- /dev/null +++ b/docs/architecture/secure-local-persistence-architecture.md @@ -0,0 +1,211 @@ +# Secure Local Persistence Architecture + +## 目标 + +这次补丁保持现有 UI 不变,只重设计 `XWorkmate` 的本地配置与任务会话持久层,满足两个约束: + +- 本地配置和任务会话必须能跨重启、跨覆盖安装恢复。 +- 持久化以前提 `secure storage` 为本地信任根,避免把可恢复状态明文落盘。 + +核心结论: + +- `FlutterSecureStorage` 仍是长期 secret 的主存储。 +- 本地配置和任务会话不直接明文写入 SQLite / JSON,而是先用本地状态密钥加密后再落盘。 +- 本地状态密钥本身必须优先保存在主 secure storage,不再把它当成普通可降级 secret。 + +## Trust Boundary + +需要明确区分 3 类状态: + +1. 用户输入的高敏感 secret + - Gateway shared token + - Gateway password + - AI Gateway API key + - Vault token + +2. 可恢复但不应明文落盘的本地状态 + - `SettingsSnapshot` + - Assistant 任务线程记录 + - 最后活动线程 + - 本地恢复 backup + +3. 仅调试或测试环境可接受的替代路径 + - 注入式 secure storage client + - 临时文件型 secure storage fallback + +边界规则: + +- 第 1 类状态优先进入 secure storage;secure storage 超时或异常时,可进入持久化 fallback 文件,但绝不退化成“仅内存”。 +- 第 2 类状态不直接进入 `SharedPreferences` 或明文 SQLite;必须先 sealed。 +- 第 3 类路径只用于 debug / test,不进入 release 行为。 + +## 架构图 + +```mermaid +flowchart TD + A["Gateway / Settings Form"] --> B["SettingsController"] + C["Assistant Thread State"] --> D["AppController"] + B --> E["SecureConfigStore"] + D --> E + + E --> F["Primary Secure Storage
FlutterSecureStorage"] + E --> G["Local State Key
xworkmate.local_state.key"] + G --> H["AES-GCM Seal / Unseal"] + + H --> I["SQLite config-store.sqlite3"] + H --> J["Durable state files
settings-snapshot.json
assistant-threads.json"] + H --> K["assistant-state-backup.json
schemaVersion=2 / sealedState"] + + E --> L["Secure secret fallback files
gateway-auth/*"] +``` + +## 存储分层 + +### 1. Primary Secure Storage + +用途: + +- 保存 Gateway token / password / AI Gateway API key / Vault token +- 保存本地状态密钥 `xworkmate.local_state.key` + +关键要求: + +- 主路径仍然是 `FlutterSecureStorage` +- 本地状态密钥不允许再走“通用 secret fallback” +- 如果主 secure storage 不可用,不允许把本地状态密钥退化成普通文件常态 + +### 2. Sealed Local State + +本地配置和任务会话的持久化结构统一改为: + +- `storageFormat = xworkmate.sealed.local-state.v1` +- `nonce` +- `cipherText` +- `mac` + +加密方式: + +- AES-GCM 256 +- 每次写入使用新的随机 nonce +- AAD 绑定存储 key,避免跨 key 错读 + +当前覆盖对象: + +- `xworkmate.settings.snapshot` +- `xworkmate.assistant.threads` +- `assistant-state-backup.json` + +### 3. Durable Recovery Files + +当 SQLite 不可用时,仍需保证本地状态可以恢复。为此保留两类耐久化文件: + +- `settings-snapshot.json` +- `assistant-threads.json` + +注意: + +- 文件名虽然保持旧风格,但内容已改为 sealed payload,不再是明文 JSON。 + +### 4. Assistant Backup + +`assistant-state-backup.json` 升级到 schema v2: + +- 用 `sealedState` 保存整体恢复快照 +- 不再把 settings / threads 明文拼进 backup + +这样做的目的: + +- 避免备份文件成为最容易泄露的明文副本 +- 保持“数据库损坏时仍可恢复”的能力 + +## 写入流程 + +### SettingsSnapshot + +1. `SettingsController` 生成新的 `SettingsSnapshot` +2. `SecureConfigStore.saveSettingsSnapshot()` 进入本地状态写队列 +3. 读取或生成 `xworkmate.local_state.key` +4. 先 sealed,再写入 SQLite / durable file / backup + +### Assistant Threads + +1. `AppController` 更新线程记录 +2. 持久化进入 `_assistantThreadPersistQueue` +3. `SecureConfigStore.saveAssistantThreadRecords()` 串行 sealed 写入 +4. 同步刷新 SQLite / durable file / backup + +这么做是为了避免异步写晚到,把旧线程快照覆盖新状态。 + +## 读取与恢复流程 + +恢复顺序: + +1. 优先读 SQLite +2. SQLite 不可用时读 durable state files +3. 若主状态缺失,再读 `assistant-state-backup.json` +4. 若读到的是旧明文格式,则立即迁移为 sealed 格式 + +迁移原则: + +- 兼容旧明文快照,避免升级后直接丢历史 +- 一旦成功恢复,就把旧格式重写成 sealed 新格式 +- legacy `SharedPreferences` 里的本地状态在迁移后会被清理 + +## Secure Secret Fallback + +Secret fallback 仍然保留,但语义变了: + +- 用于 Gateway token / password / API key 等长期 secret 的持久化兜底 +- 不再因为一次超时就退化成“仅内存” +- 这样即使 secure storage 一时不可用,重启后 secret 仍能恢复 + +约束: + +- `xworkmate.local_state.key` 不在通用 fallback 白名单里 +- 对旧版遗留的 `local-state-key.txt`,启动时做一次迁移,成功后删除 + +## Clear 行为 + +`clearAssistantLocalState()` 只清理: + +- 本地 settings snapshot +- 本地 assistant thread records +- durable state files +- assistant backup + +不会误删: + +- 已保存的 Gateway token / password +- AI Gateway API key +- Vault token +- 其他 secure refs + +## Debug / Test 策略 + +为了让测试稳定运行,新增了可注入的 secure storage 层: + +- `SecureStorageClient` +- `FlutterSecureStorageClient` +- `FileSecureStorageClient` +- `MemorySecureStorageClient` + +策略是: + +- release:使用真实 `FlutterSecureStorage` +- debug / test:允许走注入式或文件型 secure storage,保证单测和回归可跑 + +这不会改变 release 的安全边界。 + +## 与现有 UI 的关系 + +这次补丁不改: + +- Gateway 设置页结构 +- Assistant 任务线程 UI +- 模型、skills、入口按钮布局 + +变化只在持久层和恢复链路: + +- 重启后不再因为 secure storage 一次超时而丢本地配置 +- 覆盖安装后本地配置与任务会话仍可恢复 +- 本地 snapshot / backup 不再以明文保存 diff --git a/docs/cases/secure-local-persistence-postmortem.md b/docs/cases/secure-local-persistence-postmortem.md new file mode 100644 index 00000000..d8b134bc --- /dev/null +++ b/docs/cases/secure-local-persistence-postmortem.md @@ -0,0 +1,181 @@ +# Secure Local Persistence Postmortem + +## 问题摘要 + +用户现场反馈很直接: + +- 当前会话里 Gateway 可以正常连接 +- App 一重启,本地配置和已保存凭证丢失 +- `Gateway 访问` 页重新出现 `gateway token missing` + +这不是单点 bug,而是持久层设计里连续几处降级路径叠加后的结果。 + +## 用户可见症状 + +### 1. 重启后网关凭证丢失 + +表现: + +- token / password 在当前会话内可用 +- 退出再打开后不可用 +- 首次连接提示重新输入 shared token + +### 2. 本地配置或任务会话恢复不稳定 + +表现: + +- settings snapshot 或 assistant threads 在某些路径下恢复失败 +- backup 虽然存在,但仍可能是明文旧格式 + +### 3. 明文本地状态残留 + +表现: + +- 旧版 `SharedPreferences` 和 SQLite 中存在明文 settings / threads +- backup 文件也可能保留明文副本 + +## 根因 + +## 根因 1:对 `FlutterSecureStorage` 强制套了 400ms 超时 + +旧逻辑: + +- secure storage 读写只要超过 `400ms`,就视为失败 +- 一旦失败,直接退化成“仅内存” + +结果: + +- 当前进程内看起来一切正常 +- 因为值实际上没持久化,进程退出后凭证全部丢失 + +这是这次“重启后 token 消失”的直接根因。 + +## 根因 2:secure storage 失败时降级策略设计错了 + +旧策略把“可恢复 secret”误当成“会话临时缓存”处理: + +- token / password / API key 没写进 durable fallback +- 只保存在进程内存 + +这个策略对调试场景看似友好,但对桌面 App 的真实使用是灾难性的,因为用户天然预期“已经保存”的 secret 会跨重启存在。 + +## 根因 3:legacy prefs 迁移把明文直接写回了主存储 + +迁移链路里存在一个关键缺口: + +- 从 `SharedPreferences` 读取到旧版明文 settings / threads +- 直接调用数据库写入 +- 没有经过 sealed local state + +结果: + +- 用户完成升级后,本地状态仍可能继续以明文形式存在 SQLite +- 旧的 pref key 也没有被及时清理 + +这让“升级到新版本后自动变安全”的承诺失效了。 + +## 根因 4:本地状态密钥也被允许走普通 fallback + +旧版把 `xworkmate.local_state.key` 当成普通 secret 处理。 + +结果: + +- 一旦它掉进 fallback 文件,secure storage 就不再是本地状态加密的真正前提 +- 架构上变成“有 secure storage 更好,没有也能常态运行” + +这违背了本次补丁要建立的安全模型。 + +## 根因 5:线程状态异步保存存在覆盖竞态 + +Assistant 线程会话是异步落盘的。旧逻辑没有串行 flush: + +- 线程 A 的旧快照可能在稍后写入 +- 覆盖线程 B 或更新后的新状态 + +在加密封装增加写入成本后,这个竞态更容易暴露。 + +## 修复策略 + +### 1. secure storage 不再 400ms 即判死刑 + +- 超时提高到 `5s` +- 对真实 `FlutterSecureStorage` 保留超时保护 +- 对测试注入 client 不套这层超时 + +### 2. secure storage 失败时改为 durable fallback,而不是仅内存 + +- Gateway token +- Gateway password +- AI Gateway API key +- Vault token + +这些 secret 在 secure storage 异常时会写入持久化 fallback,保证跨实例恢复。 + +### 3. 本地配置和任务会话统一 sealed + +对以下状态统一改为 AES-GCM sealed payload: + +- `SettingsSnapshot` +- Assistant thread records +- `assistant-state-backup.json` + +目标是消除明文 SQLite / 明文 JSON backup。 + +### 4. legacy 明文状态迁移时立即重写并清理旧 pref + +新逻辑: + +- 读旧 pref +- 若目标存储不存在,则按 sealed 路径写入 +- 写入成功后删除旧 pref key + +这样升级后不会继续遗留明文主副本。 + +### 5. 本地状态密钥升级为 primary secure storage only + +`xworkmate.local_state.key` 现在的规则是: + +- 必须优先保存在主 secure storage +- 不再纳入普通 secure fallback 白名单 +- 对旧版 `local-state-key.txt` 仅做一次迁移,随后删除 + +### 6. Assistant 线程持久化改为串行队列 + +新增线程持久化 queue 和 flush 机制,保证: + +- 新状态不会被晚到的旧写入覆盖 +- clear / send / view-mode 切换前可以先 flush + +### 7. dispose 后的异步通知保护 + +`SettingsController` 新增 dispose guard,避免恢复链路异步完成后向已销毁对象 `notifyListeners()`。 + +## 为什么旧方案会失效 + +旧方案的问题不在“没加密”,而在于它把三件不同的事混在了一起: + +- 当前请求是否可用 +- 是否已经持久化 +- 是否已经安全持久化 + +一旦 secure storage 稍慢,系统就会把“当前连接可继续”错误地当成“数据已经保存”,这正是桌面应用里最危险的误导。 + +## 回归防线 + +这次新增的回归覆盖重点包括: + +- secure storage 超时后 secret 仍能跨实例恢复 +- SQLite 不可用时,sealed 的 settings / threads 仍能恢复 +- plaintext local state 能迁移为 sealed storage +- legacy `local-state-key.txt` 能迁移到主 secure storage 并被清理 +- backup 文件不再泄露明文 settings / threads + +## 后续约束 + +后续所有涉及本地状态持久化的修改,都必须继续满足: + +- `.env` 仍是预填,不是持久化真值 +- 当前用户发起连接时可直接用表单值握手,不依赖 secure-store 回读 +- local state 不得重新落回 `SharedPreferences` 明文 +- backup 不得重新变成明文副本 +- 不能再让 `xworkmate.local_state.key` 走常态文件 fallback diff --git a/docs/releases/2026-03-22-secure-persistence-release-update.md b/docs/releases/2026-03-22-secure-persistence-release-update.md new file mode 100644 index 00000000..cd560577 --- /dev/null +++ b/docs/releases/2026-03-22-secure-persistence-release-update.md @@ -0,0 +1,117 @@ +# 2026-03-22 Secure Persistence Release Update + +## 摘要 + +这次补丁修复的是一个发版级问题: + +- `XWorkmate.app` 在某些机器上重启后会丢失本地 Gateway 配置和已保存凭证 +- Assistant 本地任务线程和恢复快照的持久化链路存在明文残留和竞态风险 + +本次发布不改 UI,只修正持久层与恢复链路。 + +## 用户可感知变化 + +### 1. 重启后本地配置不应再消失 + +修复后: + +- Gateway host / port / TLS 等本地配置继续恢复 +- 已保存的 shared token / password 不再因为一次 secure storage 超时而只留在内存里 + +### 2. 覆盖安装后本地状态仍应保留 + +修复后: + +- `/Applications/XWorkmate.app` 覆盖安装不会清掉本地配置和任务会话 +- Assistant 最后活动线程与消息历史应继续可恢复 + +### 3. 本地快照不再明文持久化 + +修复后: + +- `SettingsSnapshot` +- Assistant thread records +- `assistant-state-backup.json` + +都改为 sealed local state,而不是明文 JSON/SQLite。 + +## 核心修复点 + +- `SecureConfigStore` 的 secure storage 超时从 `400ms` 调整到 `5s` +- secure storage 超时/异常时,secret 改为 durable fallback,而不是“只存内存” +- 本地配置与任务线程统一做 AES-GCM sealed persistence +- `assistant-state-backup.json` 升级为 schema v2,使用 `sealedState` +- legacy plaintext prefs / local-state key fallback 增加迁移与清理 +- Assistant 线程持久化改为串行队列,避免异步晚到覆盖新状态 + +## 自动化验收 + +已执行结果: + +- `flutter analyze`:通过 +- `flutter test`:未作为整套 baseline 通过,当前在 `test/features/ai_gateway_page_test.dart` 的 `Settings external agents detail shows Codex bridge runtime states` case 后挂住,未产生断言失败,但进程不退出 +- `flutter test test/runtime/secure_config_store_test.dart test/runtime/app_controller_execution_target_switch_test.dart test/runtime/app_controller_ai_gateway_chat_test.dart test/features/settings_ai_gateway_persistence_test.dart test/runtime/app_controller_gateway_token_state_test.dart`:通过 +- `flutter test integration_test/desktop_navigation_flow_test.dart -d macos`:通过 +- `flutter test integration_test/desktop_settings_flow_test.dart -d macos`:通过 +- `flutter build macos --release`:通过 +- `flutter build ios --simulator`:通过 +- `make install-mac`:通过 + +补充说明: + +- 两个 macOS integration 都出现 `Failed to foreground app; open returned 1`,但设备跑断言本身通过,输出包含 `All tests passed!` +- 当前未把挂住的 `ai_gateway_page_test` 假定为通过;它被保留为现有测试阻塞项 + +## 当前机器实机复测 + +已在当前机器完成两轮宿主级复测。 + +第一轮,重启恢复: + +1. 配置本地 Gateway +2. 退出 App +3. 重新打开确认配置和任务会话仍在 + +第二轮,覆盖安装恢复: + +1. 再次执行 `make install-mac` +2. 重新打开 `/Applications/XWorkmate.app` +3. 复查本地状态持久化产物 + +结果: + +- `/Applications/XWorkmate.app` 可正常重新打开 +- 本地 SQLite 状态仍为 sealed payload,没有回退成明文 +- `assistant-state-backup.json` 仍为 `schemaVersion = 2` 且包含 `sealedState` +- legacy `SharedPreferences` 中的 `flutter.xworkmate.settings.snapshot` 在新版 App 启动后一轮迁移后被清理 +- `gateway-auth/` 目录下未再残留 `local-state-key.txt` +- 第二次覆盖安装后,上述状态保持不变 + +## 宿主级检查 + +需要确认: + +- `config-store.sqlite3` 中的本地状态是 sealed payload,而不是明文 JSON +- `assistant-state-backup.json` 为 schema v2 且包含 `sealedState` +- `settings-snapshot.json` / `assistant-threads.json` 如果存在,内容也应为 sealed payload +- 不出现明文 token / password +- 旧版 `local-state-key.txt` 若存在,应完成一次迁移并被清理 + +当前机器检查结果: + +- `config-store.sqlite3`:通过 +- `assistant-state-backup.json`:通过 +- `settings-snapshot.json` / `assistant-threads.json`:存在且为 sealed payload +- 明文 `token/password`:未发现 +- `local-state-key.txt`:未发现,说明旧文件已迁移并清理 + +## 兼容与边界 + +- `.env` 仍然只是 Settings -> Integrations -> Gateway 的预填来源,不会变成持久化真值源 +- 用户发起连接时,仍然使用当前表单值做即时握手,不依赖 secure-store 回读 +- UI 布局不变,只修改持久化和恢复逻辑 + +## 相关文档 + +- [Secure Local Persistence Architecture](/Users/shenlan/workspaces/cloud-neutral-toolkit/XWorkmate.svc.plus/docs/architecture/secure-local-persistence-architecture.md) +- [Secure Local Persistence Postmortem](/Users/shenlan/workspaces/cloud-neutral-toolkit/XWorkmate.svc.plus/docs/cases/secure-local-persistence-postmortem.md) diff --git a/docs/releases/2026-03-22-secure-persistence-social-copy.md b/docs/releases/2026-03-22-secure-persistence-social-copy.md new file mode 100644 index 00000000..d5482973 --- /dev/null +++ b/docs/releases/2026-03-22-secure-persistence-social-copy.md @@ -0,0 +1,83 @@ +# 2026-03-22 Secure Persistence Social Copy + +## X + +```text +XWorkmate 刚发了一个很关键的稳定性补丁: + +修复了 macOS App 在重启 / 覆盖安装后,本地 Gateway 配置、已保存凭证和任务会话可能丢失的问题。 + +这次没有改 UI,重点是把本地 settings、assistant threads 和 recovery backup 全部切到 secure-storage 前提下的 sealed persistence。 + +结果很直接: +- 重启后状态不再丢 +- 覆盖安装后状态继续保留 +- 本地 snapshot / backup 不再明文落盘 + +#Flutter #macOS #AIGateway #SecurityEngineering +``` + +## 领英 + +```text +我们刚完成了 XWorkmate 一次很典型、也很值得发出来的桌面应用可靠性修复。 + +问题表面上看是“App 重启后本地配置丢失”,但根因并不只是一个保存 bug。我们最终定位到几层叠加问题: + +1. Secure storage 读写被硬性套了 400ms 超时,超时后直接退化成“只存内存” +2. 本地 settings / task session 的恢复链路里还残留 plaintext migration 路径 +3. Assistant thread 的异步持久化存在晚到覆盖新状态的竞态 + +这次修复后,我们把本地持久层重构为: + +- FlutterSecureStorage 作为 secret 和 local-state key 的主信任根 +- SettingsSnapshot、assistant thread records、recovery backup 统一做 AES-GCM sealed persistence +- SQLite 不可用时,仍通过 sealed durable files 保证可恢复 +- secure storage 失败时,长期 secret 进入 durable fallback,而不是消失在会话内存里 + +对用户来说,变化是简单的: + +- 重启后,Gateway 配置和任务会话不再丢 +- 覆盖安装后,本地状态继续保留 +- 本地 snapshot / backup 不再明文落盘 + +这类修复的价值,不在于“加了加密”四个字,而在于把“当前请求可用”“已经持久化”“已经安全持久化”这三件事重新分层,并让产品行为和用户预期重新对齐。 + +#SoftwareArchitecture #SecurityEngineering #Flutter #DesktopApp #Reliability +``` + +## 小红书 + +```text +最近把 XWorkmate 修了一个很真实的坑,值得单独记一笔。 + +用户反馈是: +“这次明明连上了,为什么重启以后本地配置又没了?” + +一开始看像保存没写进去,继续往下查才发现问题更深: + +1. secure storage 只要慢一点,旧逻辑 400ms 就判失败 +2. 失败后不是写持久化兜底,而是直接退成“只存内存” +3. 所以当前会话看起来正常,App 一退出,token / password 就跟着没了 +4. 更糟的是,本地 settings 和任务会话的旧迁移链路里还残留明文落盘 + +这次补丁做了几件事: + +- secure storage 超时从 400ms 提到 5s +- secret 异常时走 durable fallback,不再只活在内存里 +- 本地 settings、assistant threads、backup 全部改成 sealed persistence +- 修掉旧版 plaintext migration +- 补上 assistant thread 持久化竞态保护 + +结果就是: + +- 重启后本地 Gateway 配置不再丢 +- 覆盖安装后任务会话还能回来 +- 本地 snapshot / backup 不再是明文 + +这类问题最难的点,不是修一行代码,而是把“能连上”和“真的保存了”分开看。 + +桌面 App 做到最后,用户要的不是一个当下能跑的 demo,而是一个重启以后还记得自己的工具。 + +#独立开发 #Flutter #桌面应用 #AI工具 #产品修复复盘 +```