docs: add secure persistence architecture and release pack

This commit is contained in:
Haitao Pan 2026-03-22 13:34:29 +08:00
parent c7d15d342d
commit 3798e2f45b
4 changed files with 592 additions and 0 deletions

View File

@ -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 storagesecure 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<br/>FlutterSecureStorage"]
E --> G["Local State Key<br/>xworkmate.local_state.key"]
G --> H["AES-GCM Seal / Unseal"]
H --> I["SQLite config-store.sqlite3"]
H --> J["Durable state files<br/>settings-snapshot.json<br/>assistant-threads.json"]
H --> K["assistant-state-backup.json<br/>schemaVersion=2 / sealedState"]
E --> L["Secure secret fallback files<br/>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 不再以明文保存

View File

@ -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 消失”的直接根因。
## 根因 2secure storage 失败时降级策略设计错了
旧策略把“可恢复 secret”误当成“会话临时缓存”处理
- token / password / API key 没写进 durable fallback
- 只保存在进程内存
这个策略对调试场景看似友好,但对桌面 App 的真实使用是灾难性的,因为用户天然预期“已经保存”的 secret 会跨重启存在。
## 根因 3legacy 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

View File

@ -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)

View File

@ -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工具 #产品修复复盘
```