Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@ on:

jobs:
test:
runs-on: windows-latest
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os:
- windows-latest
- ubuntu-latest
node-version:
- "16"
- "24"
Expand All @@ -33,3 +36,13 @@ jobs:
dotnet-version: "10.0.x"
- run: dotnet test desktop/CodexProviderSync.Core.Tests/CodexProviderSync.Core.Tests.csproj
- run: dotnet test desktop/CodexProviderSync.App.Tests/CodexProviderSync.App.Tests.csproj

desktop-macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
- run: dotnet test desktop/CodexProviderSync.Core.Tests/CodexProviderSync.Core.Tests.csproj
- run: dotnet build desktop/CodexProviderSync.Mac/CodexProviderSync.Mac.csproj --configuration Release
27 changes: 24 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ For normal Windows users, prefer the GUI app when it is available. Use the CLI w
The tool works by updating both:

- rollout metadata under `~/.codex/sessions` and `~/.codex/archived_sessions`
- SQLite thread metadata in the detected Codex state database, normally
`~/.codex/sqlite/state_5.sqlite` with legacy fallback to
`~/.codex/state_5.sqlite`
- SQLite thread metadata in the resolved Codex state database

Resolve SQLite Home in this order: explicit CLI/GUI override, root `sqlite_home` in `config.toml`, `CODEX_SQLITE_HOME`, then `<codex-home>/sqlite`. Only the default layout may fall back to `<codex-home>/state_5.sqlite`. Never fall back when an explicit/config/environment SQLite Home is missing.

On Windows, `\\wsl.localhost\...` and `\\wsl$\...` SQLite Homes are diagnostic-only. SQLite operations for these paths run inside WSL and use the corresponding Linux path.

Do not solve this by manually editing rollout files only unless the user explicitly asks for manual intervention.

Expand Down Expand Up @@ -87,6 +89,9 @@ GUI mapping:
- `Execute` with config checkbox = switch-like behavior
- `Restore Backup` = restore a previous backup
- backup retention defaults to 5 and can be customized in the GUI
- SQLite Home overrides are stored per Codex Home in app settings and are passed to refresh, sync, switch, and restore
- Windows GUI refresh reports WSL UNC SQLite Homes as diagnostic-only paths; Execute and Restore are disabled for that layout
- restoring a metadata v2 backup to a different SQLite Home requires a second confirmation showing source and target
- `Clean Old Backups` = prune managed backups down to the selected retention count

## Important Behavior
Expand All @@ -107,6 +112,12 @@ If the output says `state_5.sqlite is currently in use`:
- tell the user to close Codex, Codex App, and app-server
- then rerun the same command

If the output says Windows cannot safely access SQLite through a WSL UNC path:

- identify the message as a WSL UNC path safety diagnostic
- open the corresponding WSL distribution
- run the CLI there with the Windows Codex Home mounted under `/mnt/<drive>/...` and SQLite Home expressed as a Linux `/home/...` path

If sync reports `Skipped locked rollout files`:

- treat the sync as mostly successful
Expand All @@ -128,6 +139,9 @@ If `switch <provider-id>` fails because the provider is missing:
- by default the tool keeps the most recent 5 managed backups
- use GUI retention settings or CLI `--keep <n>` when the user wants a different retention count
- do not edit `state_5.sqlite` or rollout files manually if the tool can do it
- classify WSL UNC messages as path safety diagnostics and route SQLite operations through WSL with Linux paths
- metadata v2 backups record `sqliteHome` and `sqliteDbFiles`; a missing default-layout database may be rebuilt from a valid backup, but a missing explicit/config/environment database remains an error
- CLI restore to a different SQLite Home requires `--sqlite-home`, `--allow-sqlite-home-relocation`, and `--no-config`; desktop apps must reject relocation while config restore is selected
- GUI settings live in `%AppData%\codex-provider-sync\settings.json`

## Recommended Commands
Expand All @@ -152,6 +166,13 @@ codex-provider sync --codex-home C:\Users\you\.codex
codex-provider switch openai --codex-home C:\Users\you\.codex
```

From WSL when Codex Home is on Windows and SQLite Home is in WSL:

```bash
codex-provider status --codex-home /mnt/c/Users/you/.codex --sqlite-home /home/you/.codex/sqlite
codex-provider sync --codex-home /mnt/c/Users/you/.codex --sqlite-home /home/you/.codex/sqlite
```

## One-Shot Prompt Template

Use this prompt in another AI tool if the user wants one-step handling:
Expand Down
22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Codex 切换 `model_provider` 后,旧会话可能从 Desktop 或 `/resume` 中
## 它会处理什么

- 同步 `~/.codex/sessions` 和 `~/.codex/archived_sessions` 中的 rollout metadata。
- 同步 Codex SQLite 线程记录;优先检测 `~/.codex/sqlite/state_5.sqlite`,并兼容旧路径 `~/.codex/state_5.sqlite`
- 同步 Codex SQLite 线程记录,并支持 SQLite 与 `Codex Home` 分开存放
- 修复项目可见性相关路径信息,并在需要时同步相关 model metadata。
- 每次同步前自动备份,支持恢复和清理旧备份。
- 大型 rollout 文件在满足条件时原地更新,否则自动使用完整安全重写。
Expand All @@ -49,6 +49,8 @@ Codex 切换 `model_provider` 后,旧会话可能从 Desktop 或 `/resume` 中

GUI 会保留备份并显示同步结果。每天首次启动会在后台检查一次稳定版更新,网络查询最多等待 10 秒;也可以随时手动检查。执行日志保存在 `%AppData%\codex-provider-sync\logs`。

Windows GUI 支持为每个 Codex Home 单独指定 Windows 文件系统中的 SQLite Home。`\\wsl.localhost\...` 和 `\\wsl$\...` 一类 WSL UNC 路径仅用于安全诊断;GUI 会显示诊断信息并禁用同步和恢复。Windows Codex Home + WSL SQLite Home 场景应在 WSL 内运行 CLI。

项目目前未做 Windows 代码签名,从浏览器下载后可能出现 SmartScreen 提示。请从本项目 Release 下载,并按需核对同版本 SHA-256。

Windows 完整说明见 [README_GUI_ZH.md](docs/README_GUI_ZH.md)。macOS 用户可自行构建 Avalonia 桌面版,参见 [README_MAC_GUI_ZH.md](docs/README_MAC_GUI_ZH.md)。
Expand All @@ -75,7 +77,18 @@ codex-provider sync
| `codex-provider watch` | 监听配置、SQLite 和 WAL 变化并自动同步 |
| `codex-provider watch --once` | 第一次变化并成功同步后退出 |

`switch` 支持 `--model <NAME>` 显式设置根级 model,或使用 `--keep-root-model` 只切换 Provider。所有主要命令都支持 `--codex-home <PATH>`。
`switch` 支持 `--model <NAME>` 显式设置根级 model,或使用 `--keep-root-model` 只切换 Provider。所有主要命令都支持 `--codex-home <PATH>` 和 `--sqlite-home <PATH>`。

SQLite Home 按以下顺序解析:命令行 override → `config.toml` 根级 `sqlite_home` → `CODEX_SQLITE_HOME` → `<Codex Home>/sqlite`。只有最后一种默认布局会继续检查旧路径 `<Codex Home>/state_5.sqlite`;一旦显式指定 SQLite Home,就不会回退到 Codex Home 中的旧数据库。

例如 Codex App 使用 Windows 配置、app-server 与 SQLite 位于 WSL 时,可在 WSL CLI 中直接传入:

```bash
codex-provider status --codex-home /mnt/c/Users/you/.codex --sqlite-home /home/you/.codex/sqlite
codex-provider sync --codex-home /mnt/c/Users/you/.codex --sqlite-home /home/you/.codex/sqlite
```

`status` 会显示 effective SQLite Home 和来源。显式路径缺少 `state_5.sqlite` 时,状态查询只报告诊断,`sync`、`switch` 和数据库恢复不会偷偷回退到其它位置。默认布局中的数据库被删除时,`restore` 可以根据备份 metadata 在原默认位置重建数据库。

## 安全与限制

Expand All @@ -88,6 +101,8 @@ codex-provider sync
- 不修改消息历史、会话标题、认证信息、`auth.json` 或 `updated_at`。
- 不在多台设备之间复制配置或会话文件;它只修复当前 Codex Home 的 metadata。
- SQLite 被占用时,需要先关闭 Codex、Codex App 和 app-server 后重试。
- Windows 进程检测到 WSL UNC SQLite Home 时会立即显示专用安全诊断并停止操作。后续操作应进入对应 WSL 发行版,并使用 `/home/...` 形式的 Linux 路径运行 CLI。
- 新备份使用 metadata v2 记录独立 SQLite Home;恢复到其它 SQLite Home 默认拒绝。CLI 需要同时传入 `--sqlite-home`、`--allow-sqlite-home-relocation` 和 `--no-config`,避免恢复后的 `config.toml` 重新指向原 SQLite Home。
- 活跃会话锁住 rollout 文件时,工具会跳过该文件并继续处理其它会话;结束活跃会话后可再次同步。
- 含 `encrypted_content` 的会话跨 Provider/account 后,可能只能恢复列表可见性,继续对话或 compact 仍可能报 `invalid_encrypted_content`。
- Codex Desktop 首屏目前只显示最近 50 条会话。若 `/resume` 可见但项目侧仍不显示,请查看状态中的 `first page` / `ranks` 诊断;本工具不会修改时间戳来绕过此限制。
Expand All @@ -106,10 +121,13 @@ git clone https://github.com/Dailin521/codex-provider-sync.git
cd codex-provider-sync
npm test
dotnet test desktop/CodexProviderSync.Core.Tests/CodexProviderSync.Core.Tests.csproj
./scripts/test-wsl-unc-safety.sh
pwsh ./scripts/publish-gui.ps1
./scripts/publish-gui-macos.sh
```

`test-wsl-unc-safety.sh` 需要从 WSL 运行,并调用 Windows `dotnet.exe` 验证真实 WSL ext4 SQLite 的安全阻断。

## License

MIT
88 changes: 88 additions & 0 deletions desktop/CodexProviderSync.App.Tests/MainFormPresentationTests.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Drawing;
using System.Reflection;
using System.Windows.Forms;
using CodexProviderSync.Core;

namespace CodexProviderSync.App.Tests;

Expand All @@ -25,6 +26,8 @@ public void MainForm_UsesChineseChromeAndGreenPrimaryAction()
Assert.Equal(FlatStyle.Flat, execute.FlatStyle);

Assert.Equal("浏览...", Field<Button>(form, "_browseButton").Text);
Assert.Equal("浏览...", Field<Button>(form, "_browseSqliteHomeButton").Text);
Assert.True(string.IsNullOrEmpty(Field<TextBox>(form, "_sqliteHomeText").Text));
Assert.Equal("刷新", Field<Button>(form, "_refreshButton").Text);
Assert.Equal("打开日志目录", Field<Button>(form, "_openLogButton").Text);
Assert.Equal("就绪", Field<Label>(form, "_busyLabel").Text);
Expand All @@ -35,11 +38,96 @@ public void MainForm_UsesChineseChromeAndGreenPrimaryAction()
}
}

[Fact]
public void CaptureStorageSelection_RebindsSqliteOverrideBeforeReturningNewCodexHome()
{
string root = Path.Combine(Path.GetTempPath(), $"codex-provider-ui-test-{Guid.NewGuid():N}");
string codexHomeA = Path.Combine(root, "home-a");
string codexHomeB = Path.Combine(root, "home-b");
string sqliteHomeA = Path.Combine(root, "sqlite-a");
string sqliteHomeB = Path.Combine(root, "sqlite-b");
Directory.CreateDirectory(root);
try
{
SettingsService settingsService = new(Path.Combine(root, "settings.json"));
AppSettings settings = settingsService.RecordSqliteHomeOverride(
settingsService.RecordSqliteHomeOverride(new AppSettings(), codexHomeA, sqliteHomeA),
codexHomeB,
sqliteHomeB);
using MainForm form = new(new ExecutionLogService(root), settingsService);
SetField(form, "_settings", settings);

ComboBox codexHome = Field<ComboBox>(form, "_codexHomeCombo");
TextBox sqliteHome = Field<TextBox>(form, "_sqliteHomeText");
codexHome.Text = codexHomeA;
Assert.Equal((codexHomeA, sqliteHomeA), form.CaptureStorageSelection());
Assert.Equal(sqliteHomeA, sqliteHome.Text);

codexHome.Text = codexHomeB;
Assert.Equal((codexHomeB, sqliteHomeB), form.CaptureStorageSelection());
Assert.Equal(sqliteHomeB, sqliteHome.Text);
}
finally
{
Directory.Delete(root, recursive: true);
}
}

[Fact]
public void MainForm_DisablesSqliteActionsForUnsupportedStorage()
{
string root = Path.Combine(Path.GetTempPath(), $"codex-provider-ui-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(root);
try
{
using MainForm form = new(new ExecutionLogService(root));
SetField(form, "_currentStatus", new StatusSnapshot
{
CodexHome = @"C:\Users\user\.codex",
SqliteAccess = new SqliteAccessInfo(false, "windows-wsl-unc", "unsupported"),
CurrentProvider = new CurrentProviderInfo("openai", false),
ConfiguredProviders = ["openai"],
RolloutCounts = new ProviderCounts(),
LockedRolloutFiles = [],
UnreadableRolloutFiles = [],
EncryptedContentCounts = new ProviderCounts(),
SqliteCounts = null,
BackupRoot = root,
BackupSummary = new BackupSummary { Count = 0, TotalBytes = 0 }
});

Invoke(form, "SetBusy", false, "就绪");

Assert.False(Field<Button>(form, "_executeButton").Enabled);
Assert.False(Field<Button>(form, "_restoreButton").Enabled);
Assert.True(Field<Button>(form, "_refreshButton").Enabled);
Assert.True(Field<Button>(form, "_pruneBackupsButton").Enabled);
}
finally
{
Directory.Delete(root, recursive: true);
}
}

private static T Field<T>(MainForm form, string name) where T : class
{
return typeof(MainForm)
.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)?
.GetValue(form) as T
?? throw new InvalidOperationException($"Unable to read {name}.");
}

private static void SetField<T>(MainForm form, string name, T value)
{
FieldInfo field = typeof(MainForm).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new InvalidOperationException($"Unable to find {name}.");
field.SetValue(form, value);
}

private static void Invoke(MainForm form, string name, params object[] arguments)
{
MethodInfo method = typeof(MainForm).GetMethod(name, BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new InvalidOperationException($"Unable to find {name}.");
method.Invoke(form, arguments);
}
}
Loading
Loading