diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 3b76233..ede872e 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -13,8 +13,8 @@ body: id: version attributes: label: codex-provider-sync 版本 - description: 例如 v0.2.4。GUI 版本请填写 Release 版本;CLI 版本请填写 `npm list -g codex-provider-sync` 或安装来源。 - placeholder: v0.2.4 + description: 例如 v0.5.0。GUI 版本请填写 Release 版本;CLI 版本请填写 `npm list -g @dailin521/codex-provider-sync` 或安装来源。 + placeholder: v0.5.0 validations: required: true - type: dropdown diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 961eed2..eaddbb9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: - windows-latest - ubuntu-latest node-version: - - "16" + - "16.20.2" - "24" steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -28,6 +28,7 @@ jobs: node-version: ${{ matrix.node-version }} cache: "npm" - run: npm ci + - run: npm run web:build - run: npm test desktop-test: diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml new file mode 100644 index 0000000..dbd5d6f --- /dev/null +++ b/.github/workflows/publish-npm.yml @@ -0,0 +1,41 @@ +name: publish npm + +on: + workflow_dispatch: + +permissions: + contents: read + id-token: write + +concurrency: + group: publish-npm + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + environment: npm + steps: + - name: Require the main branch + shell: bash + run: | + if [ "$GITHUB_REF" != "refs/heads/main" ]; then + echo "npm packages may only be published from main." + exit 1 + fi + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" + package-manager-cache: false + + - run: npm ci + - run: npm run web:build + - run: npm test + - run: npm pack --dry-run --json + - run: npm publish --access public diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1e85a87..082faf1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -45,6 +45,7 @@ jobs: dotnet-version: "10.0.x" - run: npm ci + - run: npm run web:build - run: npm test - run: dotnet build CodexProviderSync.sln --configuration Release - run: dotnet test desktop/CodexProviderSync.Core.Tests/CodexProviderSync.Core.Tests.csproj --configuration Release --no-build diff --git a/.gitignore b/.gitignore index 638c30b..d661c58 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ coverage/ +web/dist/ *.tgz artifacts/ desktop/**/bin/ diff --git a/AGENTS.md b/AGENTS.md index 870da3a..a710f20 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,215 +1,75 @@ -# AI Operator Guide +# AI / Agent Operator Guide -This file is for AI assistants, coding agents, and automation tools. +This file is for AI assistants and automation working in this repository. User-facing setup and usage belong in [README.md](README.md) and `docs/`. ## Goal -Help the user make historical Codex sessions visible again after switching `model_provider`. +Restore Codex session visibility after `model_provider` changes by keeping rollout metadata and the resolved SQLite thread index aligned. Do not treat this as an authentication or account-management tool. -For normal Windows users, prefer the GUI app when it is available. Use the CLI when: +## Choose the interface -- the user explicitly wants commands -- the task is automated -- the GUI EXE is unavailable +- Prefer the Windows GUI for users who want a double-click tool and do not want Node.js. +- Prefer the Local Web UI for browser-based or cross-platform use: `codex-provider web`. +- Use the CLI for explicit command requests, automation, diagnostics, WSL paths, or when a GUI is unavailable. +- Use `CodexProviderSync.Automation.exe` only for repository development or explicit Automation work. It ships with the v0.4 Windows Release, but protocol 0.4 is experimental and is not a stable public API or a production GUI control port. -The tool works by updating both: +## Safe operating flow -- rollout metadata under `~/.codex/sessions` and `~/.codex/archived_sessions` -- SQLite thread metadata in the resolved Codex state database +1. Inspect with the UI status action or `codex-provider status`. +2. Confirm the current Provider, effective SQLite Home/database, and rollout/SQLite Provider distributions. +3. Choose `sync`, `switch`, or `restore` from the rules below. +4. Execute once; do not manually edit rollout files or SQLite when the tool can perform the operation. +5. Report the final Provider alignment, backup location, and any skipped or blocked data. -## Architecture Direction +## Choose the operation -`docs/AUTOMATION_DESIGN_NOTES.md` records the experimental 0.x direction. -It is not a public compatibility contract. No Automation executable, stable -JSONL protocol, or UI probe is currently shipped. +- `sync`: the user already changed Provider/account with CCSwitch or another tool, and `config.toml` already contains the intended root `model_provider`. +- `switch `: the user explicitly wants this tool to change the root Provider and synchronize history. Custom providers must already exist in `config.toml`; built-in `openai` is always valid. +- `switch --keep-root-model`: preserve the root `model`. +- `switch --model `: explicitly set the root `model`. +- `restore `: roll back a mistaken operation. Cross-SQLite-Home restore requires an explicit target, relocation confirmation, and no config restore. +- `prune-backups --keep `: remove only older managed backups. -For Windows GUI work: +`sync` uses the current root `model_provider`, falling back to `openai` when it is absent. Sync and switch create a backup before writing and prune only tool-managed backups according to retention. -- move UI-independent state, validation, and Core request construction into the - Application/controller layer incrementally -- keep Core authoritative for config, rollout, SQLite, backup, restore, and - storage-safety behavior -- keep WinForms handlers focused on presentation and platform interaction -- preserve observable behavior and add controller tests for each migrated slice -- prefer controller tests over adding new reflection-based MainForm business tests +## Storage and path rules -Resolve SQLite Home in this order: explicit CLI/GUI override, root `sqlite_home` in `config.toml`, `CODEX_SQLITE_HOME`, then `/sqlite`. Only the default layout may fall back to `/state_5.sqlite`. Never fall back when an explicit/config/environment SQLite Home is missing. +Resolve SQLite Home in this order: -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. +1. explicit CLI, desktop GUI, or Web profile override +2. root `sqlite_home` in `config.toml` +3. `CODEX_SQLITE_HOME` +4. `/sqlite` -Do not solve this by manually editing rollout files only unless the user explicitly asks for manual intervention. +Only the default layout may fall back to legacy `/state_5.sqlite`. A missing explicit, config, or environment SQLite Home is an error; never silently fall back elsewhere. -## Preferred Flow +On Windows, `\\wsl.localhost\...` and `\\wsl$\...` SQLite Homes are diagnostic-only. Run SQLite operations inside the matching WSL distribution, using Linux paths. Metadata v2 backups record `sqliteHome` and `sqliteDbFiles`; do not bypass relocation checks. -Use this order by default: +## Safety boundaries -1. If the GUI is available and the user is not asking for terminal commands, open `CodexProviderSync.exe` -2. Refresh and inspect the current provider plus rollout/SQLite distribution -3. Decide whether the user needs sync, switch-like behavior, or restore -4. Execute the action -5. Report whether the result is complete or partially skipped due to locked files +- Never read, copy, log, or modify `auth.json`, credentials, or tokens. Do not copy, log, modify, or expose message bodies outside the Web UI's explicit read-only History view. +- Do not change thread `updated_at` or reorder history to force visibility. +- Preserve backup-first, locking, transaction, rollback, WSL, and path-boundary behavior. +- Rollout/SQLite counts may differ briefly because of an active session; Provider distributions are the alignment signal. +- Metadata synchronization restores visibility only. Another Provider/account may be unable to decrypt existing `encrypted_content`; advise the user to return to the original Provider/account or start a new session if continuation or compact fails. +- Tests and reproduction scripts must use temporary directories or fixtures, never a real user Codex Home. -CLI fallback flow: +## Handle common outcomes -1. Run `codex-provider status` -2. Read `Current provider`, the displayed SQLite database path, and compare rollout/SQLite distribution -3. Decide whether the user needs `sync`, `switch`, or `restore` -4. Run the command -5. Report whether the result is complete or partially skipped due to locked files +- SQLite in use: stop before rollout mutation; ask the user to close Codex CLI, Codex App, app-server, and retry. +- Skipped locked rollout files: classify as partial success. List the skipped files and recommend another sync after the active session ends. +- Missing custom Provider: define it in `config.toml` or switch with the user's normal Provider tool, then run `sync`. +- Missing explicit SQLite database: keep the explicit path authoritative and report the error; do not use the legacy database. +- WSL UNC diagnostic: run the CLI in WSL with the Windows Codex Home under `/mnt//...` and a Linux SQLite Home such as `/home/...`. -## Command Selection +## Engineering direction -Use `codex-provider sync` when: +- Node CLI and Web UI share the service layer in `src/`; do not duplicate sync logic in the browser. +- .NET Core remains authoritative for config, rollout, SQLite, backup, restore, and storage safety. +- Windows GUI routes UI-independent work through the Application/controller layer; WinForms owns presentation and native platform interaction. +- macOS currently calls Core directly. Do not document an Application-layer dependency that does not exist. +- Add focused tests for behavior changes. Prefer controller tests over new reflection-based WinForms business tests. -- the user already switched auth/provider using another tool -- the current `config.toml` root `model_provider` is already correct -- the user says things like: - - "make my old sessions visible again" - - "resync my Codex history" - - "I already switched provider" +## Reporting -Use `codex-provider switch ` when: - -- the user wants to change the root `model_provider` -- the user wants one command to both switch provider and resync history - -By default `switch` also aligns the root-level `model` with the new -provider section's `model`. Use `switch --keep-root-model` -to leave the root-level `model` untouched, or -`switch --model ` to set it explicitly (e.g. when -the new provider section has no `model` field of its own, or when the -user wants to call a non-default model through a relay provider). - -Use `codex-provider restore ` when: - -- the user wants to roll back a previous sync -- the user synced to the wrong provider - -Use `codex-provider status` only when: - -- the user asks for inspection only -- you need a safe first step before deciding what to do - -## GUI Selection - -Use the GUI app when: - -- the user wants a double-click tool -- the user does not want to install Node/npm -- the user wants to visually inspect providers and backups - -GUI mapping: - -- `Refresh` = inspect current status -- `Execute` without config checkbox = `sync --provider ` -- `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 - -- `sync` uses the current root `model_provider` from `~/.codex/config.toml` -- if root `model_provider` is missing, `sync` falls back to `openai` -- `switch` changes root `model_provider`, then runs a sync -- built-in `openai` is always valid -- custom providers must already exist in `config.toml` -- the tool does not log the user in and does not manage `auth.json` -- sync and switch create a backup first, then automatically prune older managed backups -- backup pruning only touches backups created by this tool under `backups_state/provider-sync` - -## Error Handling - -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//...` and SQLite Home expressed as a Linux `/home/...` path - -If sync reports `Skipped locked rollout files`: - -- treat the sync as mostly successful -- explain that the active session still holds one or more rollout files open -- tell the user to rerun `codex-provider sync` after that session ends if they want a full rewrite - -If `switch ` fails because the provider is missing: - -- tell the user to define it in `config.toml` or switch via their existing provider tool first -- then run `codex-provider sync` - -## Safe Defaults - -- default Codex home: `~/.codex` -- detect the SQLite DB before reasoning about SQLite counts; recent Codex uses - `~/.codex/sqlite/state_5.sqlite`, while older layouts may use - `~/.codex/state_5.sqlite` -- prefer `status` before destructive-looking operations, even though this tool only edits metadata -- by default the tool keeps the most recent 5 managed backups -- use GUI retention settings or CLI `--keep ` 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 - -```bash -codex-provider status -codex-provider sync -codex-provider sync --keep 5 -codex-provider sync --provider openai -codex-provider switch apigather -codex-provider switch apigather --model "MiniMax-M3" -codex-provider switch apigather --keep-root-model -codex-provider prune-backups --keep 5 -codex-provider restore C:\Users\you\.codex\backups_state\provider-sync\20260319T042708906Z -``` - -With an explicit Codex home: - -```bash -codex-provider status --codex-home C:\Users\you\.codex -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: - -```text -I use codex-provider-sync. Please help me fix Codex session visibility under my current provider. - -Steps: -1. Run `codex-provider status`. -2. If my current provider is already correct, run `codex-provider sync`. -3. If I explicitly tell you to switch provider, run `codex-provider switch ` instead. -4. If SQLite is locked, tell me to close Codex / Codex App / app-server and retry. -5. If rollout files are skipped because they are locked, tell me which ones were skipped and remind me to rerun sync later. -6. Summarize the final state of rollout files and SQLite after the command finishes. -``` - -## User-Facing Summary Style - -When reporting results back to the user: - -- state the current provider -- state whether rollout files and SQLite are aligned -- mention backup location if a sync or switch was executed -- call out partial success clearly if locked rollout files were skipped +State the current Provider, whether rollout and SQLite metadata are aligned, the resolved database path, the backup created by a write operation, and whether the result was complete, partial, or blocked. Distinguish automated tests from real-machine validation and list anything not run. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ec8c7e..bdee749 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,59 @@ 本文件记录面向用户和集成方的重要变化。完整的发布叙事、升级说明和下载入口见对应版本的中文发布说明;实现证据和测试门禁见技术发布说明。 +## [0.5.0] - 2026-08-15 + +### 新增 + +- Web UI 改为零输入自动配对:短时一次性 fragment 换取持久设备凭证,服务端只保存凭证哈希。 +- 新增服务端存储配置,浏览器写操作仅提交 `profileId`;支持命名配置、`--reset-access` 和“忘记此浏览器”。 +- 新增 `--no-open`、SSH 隧道和无桌面环境说明;已有 Web UI 实例会被复用。 + +### 变更 + +- 最低 Node.js 版本明确为 16.20.2,CI 同时覆盖 16.20.2 和当前 LTS。 +- npm 包和仓库内发布型 .NET 项目版本统一为 0.5.0。 +- CLI/Web 通过 npm 独立发布;本次没有创建 v0.5.0 Windows GUI Release,Windows GUI 继续使用独立的 GitHub Release 版本线。 + +### 修复 + +- 服务固定监听 `127.0.0.1`,并按实际回环 Host/端口校验浏览器 Origin。 +- History 搜索增加 300ms 防抖、Enter 立即搜索、旧请求取消和最新响应守卫;切换会话时显示加载状态。 +- 浏览器打开器不可用或端口已被占用时给出可操作结果,不再以未处理错误退出。 + +### 安全 + +- 匿名首页不再下发写凭证;未配对浏览器不能调用 API 写接口。 +- 一次性配对凭证不可重用,且不进入查询参数、HTML 或服务活动日志。 + +[中文发布说明](docs/release-notes/v0.5.0-zh.md) · [技术发布说明](docs/RELEASE_NOTES_V0.5.0.md) + +## [0.4.1] - 2026-08-08 + +### 新增 + +- Release 新增独立的 Windows x64 Automation ZIP,并继续提供单文件 GUI、Windows 完整包和 SHA-256 校验文件。 +- 新增版本化中文发布说明、自动化接口中文快速入门,以及发布元数据和打包契约校验。 + +### 变更 + +- Windows Node CLI 在批量改写 rollout 时复用持久 PowerShell worker,避免为每个文件重复启动进程。 +- 合并 rollout 内容扫描并复用不可变内容摘要;事务日志在 Windows 上复用已校验的写入租约。 +- 缓存托管备份的大小与文件数统计,并在提交、恢复和回滚结束后刷新统计。 + +### 修复 + +- 活跃 rollout 在扫描期间变化时按“已跳过锁定文件”处理,不再中止其余历史会话同步。 +- 备份统计刷新失败会作为警告返回,已提交或已恢复的操作仍会继续完成清理流程。 +- 保持事务记录顺序、崩溃恢复、checked apply 重新校验和旧版备份恢复兼容性。 + +### 升级说明 + +- v0.4.0 Windows GUI 可以直接使用内置更新升级;内置更新仍只替换单文件 GUI。 +- 升级不要求迁移配置、SQLite 或备份;自动化接口用户请手动下载新的 Automation ZIP 或 Windows 完整包。 + +[中文发布说明](docs/release-notes/v0.4.1-zh.md) · [完整变更对比](https://github.com/Dailin521/codex-provider-sync/compare/v0.4.0...v0.4.1) + ## [0.4.0] - 2026-08-04 ### 新增 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a1b8bca..523919c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,11 +18,14 @@ This project welcomes issues, pull requests, documentation, and tests in either | 路径 | 内容 | | --- | --- | -| `src/` | Node.js CLI 和同步逻辑 | +| `src/` | Node.js CLI、Web 服务和共享同步逻辑 | +| `web/` | Local Web UI 前端 | | `test/` | Node.js 自动化测试 | -| `desktop/CodexProviderSync.Core/` | Windows 与 macOS GUI 共用的 .NET 应用逻辑 | +| `desktop/CodexProviderSync.Core/` | Windows 与 macOS GUI 共用的 .NET 核心逻辑 | +| `desktop/CodexProviderSync.Application/` | Windows GUI 与 Automation 共用的应用用例 | | `desktop/CodexProviderSync.App/` | Windows WinForms GUI | | `desktop/CodexProviderSync.Mac/` | macOS Avalonia GUI | +| `desktop/CodexProviderSync.Automation/` | 实验性的 Windows Automation 接口 | | `desktop/*Tests/` | .NET 自动化测试 | | `scripts/` | GUI 构建和 WSL 安全验证脚本 | | `docs/` | 用户文档和维护文档 | @@ -32,7 +35,7 @@ This project welcomes issues, pull requests, documentation, and tests in either 基础开发需要: - Git -- Node.js 16 或更高版本;CI 同时验证 Node.js 16 和 24 +- Node.js 16.20.2 或更高版本;CI 验证最低版本和当前发布矩阵 - npm - .NET 10 SDK(修改 .NET Core 或 GUI 时) - PowerShell 7(修改或验证 Windows 打包脚本时) @@ -42,6 +45,7 @@ This project welcomes issues, pull requests, documentation, and tests in either ```bash npm ci npm test +npm run web:build ``` 运行共享 Core 和 Windows GUI 测试: @@ -80,6 +84,7 @@ dotnet build desktop/CodexProviderSync.Mac/CodexProviderSync.Mac.csproj --config | --- | --- | | 文档 | 检查链接、路径和命令;同一内容有多个语言版本时保持一致 | | Node.js CLI | `npm test` | +| Local Web UI | `npm run web:build`、`npm test`;界面改动附浏览器截图或说明未手测原因 | | 共享 .NET Core | Core Tests;涉及 CLI 时同时运行 `npm test` | | Windows GUI | Core Tests、App Tests;布局改动附 Windows 截图或说明未手测原因 | | macOS GUI | Core Tests、macOS Release build;真实 macOS GUI 手测无法完成时,在 PR 中明确记录 | @@ -108,6 +113,14 @@ PR 中请特别说明: ## 准备发布 +CLI/Web npm 包和 Windows GitHub Release 独立发布,版本号可能不同。 + +### CLI / Web npm 包 + +按 [npm 发布维护指南](docs/NPM_PUBLISHING.md) 更新 `package.json` 与 `package-lock.json`、完成构建和测试,并从 `main` 手动运行受信发布工作流。仅发布 CLI/Web 时不创建 Git tag 或 Windows Release。 + +### Windows GitHub Release + 发布 tag 前需要: 1. 将 [中文发布说明模板](docs/release-notes/TEMPLATE-zh.md) 复制为 `docs/release-notes/v<版本>-zh.md`。 @@ -122,13 +135,14 @@ PR 中请特别说明: - Small fixes, tests, and documentation updates can be submitted directly as a PR. Please open an Issue before starting a large feature, behavior change, or refactor. - If you do not have write access, fork the repository, push your branch to your fork, and open a PR against this repository's `main` branch. -- Use Node.js 16 or later and run `npm ci` followed by `npm test`. Changes to shared .NET or desktop code also require the relevant .NET 10 tests listed above. +- Use Node.js 16.20.2 or later and run `npm ci`, `npm test`, and `npm run web:build`. Changes to shared .NET or desktop code also require the relevant .NET 10 tests listed above. - Automated tests and reproduction scripts must use temporary directories or fixtures and must not depend on, read, or modify a real user's `~/.codex`. Prefer a dedicated test Codex Home for manual validation and describe its scope in the PR. - Never include unredacted credentials, `auth.json`, Codex sessions, SQLite databases, backups, logs, tokens, or personal data. - Keep each PR focused. Explain why the change is needed, what it writes, which platforms it affects, what was tested, and what was not tested. - GUI changes should include screenshots and the platform and display scaling used. If real macOS GUI testing is unavailable, say so clearly; it is not automatically a reason to reject the contribution. - Update affected documentation when user-visible behavior, command options, or safety boundaries change. - All changes go through a PR and must pass `ci-gate`. +- The CLI/Web npm package and Windows GitHub Release are independent release channels; follow `docs/NPM_PUBLISHING.md` for npm releases. ## License diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md new file mode 100644 index 0000000..0595ef1 --- /dev/null +++ b/CONTRIBUTORS.md @@ -0,0 +1,26 @@ +# Contributors / 贡献者 + +Thank you to everyone who has contributed code, documentation, testing, investigation, and feedback. The following contributors have work represented on `main`; contributions are summarized from merged pull requests and commit history. + +感谢所有为项目贡献代码、文档、测试、问题调查和反馈的人。以下贡献者的成果已经进入 `main`;主要贡献根据已合并的 Pull Request 和提交历史整理。 + +| Contributor | 主要贡献 / Main contributions | +| --- | --- | +| [@cccat6](https://github.com/cccat6) | Provider 元数据原位改写及 Windows 兼容性优化 / In-place provider metadata rewrites and Windows compatibility ([#51](https://github.com/Dailin521/codex-provider-sync/pull/51)) | +| [@CCpoe](https://github.com/CCpoe) | Codex SQLite 状态数据库检测修正 / Codex SQLite state-database detection fixes | +| [@Elitos0](https://github.com/Elitos0) | macOS 构建依赖加固 / macOS build dependency hardening ([#58](https://github.com/Dailin521/codex-provider-sync/pull/58)) | +| [@Goldloli](https://github.com/Goldloli) | macOS Avalonia GUI / macOS Avalonia GUI ([#30](https://github.com/Dailin521/codex-provider-sync/pull/30)) | +| [@hanxing-go](https://github.com/hanxing-go) | 嵌套 SQLite 状态数据库问题调查 / Nested SQLite state-database investigation | +| [@Hccake](https://github.com/Hccake) | 独立 SQLite Home、恢复迁移保护和 WSL 路径安全 / Independent SQLite Home, restore relocation safeguards, and WSL path safety ([#55](https://github.com/Dailin521/codex-provider-sync/pull/55)) | +| [@JoJoJoinme](https://github.com/JoJoJoinme) | Codex 状态数据库位置检测 / Codex state-database location detection ([#36](https://github.com/Dailin521/codex-provider-sync/pull/36)) | +| [@Liuwei1125](https://github.com/Liuwei1125) | 嵌套 SQLite 状态数据库问题调查 / Nested SQLite state-database investigation | +| [@panjinhe](https://github.com/panjinhe) | Windows 同步性能、CLI 进度和瞬时锁重试 / Windows sync performance, CLI progress, and transient lock retries | +| [@PaulDing98](https://github.com/PaulDing98) | Provider/model 同步、watch 和桌面端单实例流程 / Provider/model synchronization, watch mode, and desktop single-instance flow ([#41](https://github.com/Dailin521/codex-provider-sync/pull/41), [#42](https://github.com/Dailin521/codex-provider-sync/pull/42)) | +| [@Plintun-Max](https://github.com/Plintun-Max) | 旧版 Node.js 的 SQLite fallback / SQLite fallback for older Node.js ([#40](https://github.com/Dailin521/codex-provider-sync/pull/40)) | +| [@shixiaogaoya](https://github.com/shixiaogaoya) | 内置更新流程 / Built-in update flow ([#49](https://github.com/Dailin521/codex-provider-sync/pull/49)) | +| [@sparklesea](https://github.com/sparklesea) | SQLite Home 路径问题调查 / SQLite Home path investigation | +| [@tangquanwei](https://github.com/tangquanwei) | 本地 Web UI、聊天记录浏览和多语言文档基础 / Local Web UI, history browsing, and multilingual documentation foundation ([#80](https://github.com/Dailin521/codex-provider-sync/pull/80); original proposal [#73](https://github.com/Dailin521/codex-provider-sync/pull/73)) | + +For the automatically generated commit-based view, see [GitHub Contributors](https://github.com/Dailin521/codex-provider-sync/graphs/contributors). If an attribution is missing or inaccurate, please open an issue or pull request. + +GitHub 根据提交自动生成的名单见 [GitHub Contributors](https://github.com/Dailin521/codex-provider-sync/graphs/contributors)。如果署名遗漏或描述不准确,欢迎提交 Issue 或 Pull Request 修正。 diff --git a/README.md b/README.md index d1aaf4d..3cf1c66 100644 --- a/README.md +++ b/README.md @@ -5,182 +5,170 @@ ### 切换 Provider 后,让 Codex 历史会话重新可见 [![CI](https://github.com/Dailin521/codex-provider-sync/actions/workflows/ci.yml/badge.svg)](https://github.com/Dailin521/codex-provider-sync/actions/workflows/ci.yml) -[![Release](https://img.shields.io/github/v/release/Dailin521/codex-provider-sync)](https://github.com/Dailin521/codex-provider-sync/releases/latest) +[![CLI / Web](https://img.shields.io/npm/v/%40dailin521%2Fcodex-provider-sync?label=CLI%20%2F%20Web)](https://www.npmjs.com/package/@dailin521/codex-provider-sync) +[![Windows GUI](https://img.shields.io/github/v/release/Dailin521/codex-provider-sync?label=Windows%20GUI)](https://github.com/Dailin521/codex-provider-sync/releases/latest) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +[![Community](https://img.shields.io/badge/community-LINUX%20DO-2ea043.svg)](https://linux.do/) -[下载 Windows GUI](https://github.com/Dailin521/codex-provider-sync/releases/latest) · 中文 · [English](docs/README_EN.md) +**中文** · [English](docs/README_EN.md) · [日本語](docs/README_JA.md) · [한국어](docs/README_KO.md) -## 什么时候需要它 +## 它解决什么 -Codex 切换 `model_provider` 后,旧会话可能从 Desktop 或 `/resume` 中消失。会话通常没有丢失,而是 rollout、SQLite 和项目可见性 metadata 仍指向原 Provider。 +切换 `model_provider` 后,旧会话可能从 Codex Desktop 或 `/resume` 中消失。**数据通常仍在磁盘上**,只是会话文件和 SQLite 索引中的 Provider 信息没有同步。 -适合使用本工具: +本工具会同步会话文件和 SQLite 索引,恢复会话可见性,并在写入前创建备份。它不负责登录、账号切换,也不修改 `auth.json` 或消息正文。 -- 在官方订阅(内部 Provider 为 `openai`)和自定义中转之间切换。 -- 多个配置必须使用不同的 `model_provider`,切换后旧会话不可见。 -- rollout 与 SQLite 中的 Provider 或 model 信息不一致。 -- 希望在配置或 SQLite/WAL 变化后自动重新同步。 +

+ Provider 元数据同步前后效果 +

-如果所有中转都能稳定复用同一个 `model_provider`,并且历史会话始终可见,那么统一 Provider ID 是更简单的方案,不需要额外同步。本工具主要用于无法统一 Provider ID,或需要在官方订阅与自定义 Provider 之间切换的场景。 +### 什么时候需要同步? -本工具不负责登录、认证或切换账号;请先用原有方式完成 Provider 切换,再执行同步。 +- **通常情况:**在官方 OpenAI 与自定义中转之间切换。官方固定使用 `openai`,Provider ID 会发生变化,需要同步历史。 +- **已有历史混用:**旧会话已经记录为不同的 Provider ID,需要同步到当前 Provider。 +- **无需同步:**只在共用同一 Provider ID 的自定义中转之间切换,或者 CCSwitch 等工具已经同步了历史。 -## 它与 Provider 切换工具的关系 +## 快速开始 -包括 cc-switch 在内的 Provider 管理工具,主要负责账号、API Key、`auth.json` 或 `config.toml` 的切换,有些工具也提供自己的历史会话处理能力。codex-provider-sync 刻意不接管认证,它专注于切换之后的会话可见性元数据、rollout、SQLite、备份和恢复。 +> CLI/Web 与 Windows GUI 独立发布,版本号可能不同。 -如果你正在使用的切换工具已经能让全部历史会话保持可见,就不需要重复同步。以下情况仍适合使用本工具: +| 场景 | 推荐入口 | +| --- | --- | +| Windows 桌面 | [下载 Windows GUI](https://github.com/Dailin521/codex-provider-sync/releases/latest) · [使用说明](#windows-gui) | +| macOS 桌面 | [本地 Web UI(需 CLI)](#本地-web-ui);[原生 GUI 构建说明](docs/README_MAC_GUI_ZH.md) | +| 需要浏览器界面或跨平台使用 | [本地 Web UI(需 CLI)](#本地-web-ui) | +| 脚本、CI 或 WSL | [CLI](#cli) | -- 使用多个切换工具,或先切换后才发现旧会话已经按 Provider 分开。 -- 需要同时核对并修复 rollout、SQLite 和项目可见性,而不只修改配置文件。 -- SQLite Home 与 Codex Home 分开存放,特别是 Windows Codex Home + WSL SQLite Home。 -- 需要可恢复的批量同步、明确的备份记录和事务回滚保护。 +### Windows GUI -## 它会处理什么 +从 [Releases](https://github.com/Dailin521/codex-provider-sync/releases/latest) 下载 `CodexProviderSync.exe`: -- 同步 `~/.codex/sessions` 和 `~/.codex/archived_sessions` 中的 rollout metadata。 -- 同步 Codex SQLite 线程记录,并支持 SQLite 与 `Codex Home` 分开存放。 -- 修复项目可见性相关路径信息,并在需要时同步相关 model metadata。 -- 每次同步前自动备份,支持恢复和清理旧备份。 -- 大型 rollout 文件在满足条件时原地更新,否则自动使用完整安全重写。 -- CLI `watch` 可监听 `config.toml`、SQLite 及 WAL 变化并自动同步。 +1. 点击“刷新”。 +2. 选择目标 Provider。 +3. 点击“立即同步”。 -## 快速使用 +程序未做代码签名,Windows 可能显示安全警告。请只从本项目 Releases 下载。 -### Windows GUI +[Windows GUI 完整说明](docs/README_GUI_ZH.md) + +### 本地 Web UI + +本地 Web UI 由 CLI 提供。安装 Node.js `16.20.2+` 后,安装本项目官方 npm 包并启动: + +```bash +npm install -g @dailin521/codex-provider-sync +codex-provider web +``` + +

+ Web UI 概览 +

-普通 Windows 用户只需从 [Releases](https://github.com/Dailin521/codex-provider-sync/releases/latest) 下载单文件 GUI: +常用选项: -| 使用场景 | Release 资产 | 更新方式 | -| --- | --- | --- | -| 只需要 Windows GUI | `CodexProviderSync.exe` | 支持软件内自动更新 | -| 脚本、CI 或 AI Agent | `codex-provider-sync-v<版本>-automation-win-x64.zip` | 手动下载更新 | -| GUI 与自动化接口都需要 | `codex-provider-sync-v<版本>-win-x64.zip` | 手动下载更新 | +```bash +codex-provider web --no-open # 不自动打开浏览器 +codex-provider web --port 8792 # 指定端口 +codex-provider web --reset-access # 重新配对浏览器 +``` -1. 打开 `CodexProviderSync.exe` -2. 点击“刷新” -3. 选择目标 Provider -4. 点击“立即同步” +Web UI 默认只监听 `127.0.0.1`,并自动打开浏览器完成配对。存储路径由页面顶部的存储配置(Profile)管理,写操作需要确认。 -GUI 会保留备份并显示同步结果。每天首次启动会在后台检查一次稳定版更新,网络查询最多等待 10 秒;也可以随时手动检查。执行日志保存在 `%AppData%\codex-provider-sync\logs`。 +#### 切换 Provider 后同步历史 -Windows GUI 支持为每个 Codex Home 单独指定 Windows 文件系统中的 SQLite Home。`\\wsl.localhost\...` 和 `\\wsl$\...` 一类 WSL UNC 路径仅用于安全诊断;GUI 会显示诊断信息并禁用同步和恢复。Windows Codex Home + WSL SQLite Home 场景应在 WSL 内运行 CLI。 +1. 使用 CCSwitch 等常用工具切换 Provider。 +2. 在 Web UI 点击“读取状态”(可跳过)。 +3. 保持“仅同步元数据”,选择目标 Provider(供应商),确认执行同步。 +4. 显示“Provider 元数据已对齐”即完成。 -项目目前未做 Windows 代码签名,从浏览器下载后可能出现 SmartScreen 提示。请从本项目 Release 下载,并按需核对同版本 SHA-256。 +> **注意:** 元数据同步只能恢复历史可见性。跨供应商继续旧会话时,目标后端可能无法解密会话中的 `encrypted_content` 推理内容,导致继续对话或压缩(compact)失败。 -Windows 完整说明见 [README_GUI_ZH.md](docs/README_GUI_ZH.md)。macOS 用户可自行构建 Avalonia 桌面版,分别参见 [中文说明](docs/README_MAC_GUI_ZH.md)和[英文说明](docs/README_MAC_GUI_EN.md)。 +[Web UI 完整说明](docs/README_WEB_UI_ZH.md) ### CLI -CLI 支持 Node.js `16+`: +CLI 支持 Node.js `16.20.2+`。安装 Node.js 后,安装本项目官方 npm 包: ```bash -npm install -g git+https://github.com/Dailin521/codex-provider-sync.git +npm install -g @dailin521/codex-provider-sync codex-provider status codex-provider sync ``` -常用命令: - | 命令 | 用途 | | --- | --- | -| `codex-provider status` | 检查当前 Provider、rollout、SQLite 和项目可见性 | -| `codex-provider sync` | 将历史会话同步到当前 Provider,不修改登录状态 | -| `codex-provider switch ` | 修改根级 `model_provider` 后执行同步 | -| `codex-provider restore ` | 从指定备份恢复 | -| `codex-provider prune-backups --keep 5` | 只保留最近 5 份托管备份 | -| `codex-provider watch` | 监听配置、SQLite 和 WAL 变化并自动同步 | -| `codex-provider watch --once` | 第一次变化并成功同步后退出 | +| `codex-provider status` | 检查 Provider、rollout 和 SQLite 状态 | +| `codex-provider sync` | 同步到当前 Provider | +| `codex-provider switch ` | 切换 Provider 后同步 | +| `codex-provider restore ` | 恢复备份 | +| `codex-provider watch` | 监听配置和 SQLite 变化 | -`switch` 支持 `--model ` 显式设置根级 model,或使用 `--keep-root-model` 只切换 Provider。所有主要命令都支持 `--codex-home ` 和 `--sqlite-home `。 +`switch` 默认会在目标 Provider section 定义了 `model` 时同步根级 `model`。使用 `--keep-root-model` 保留当前值,或使用 `--model ` 显式指定。 -SQLite Home 按以下顺序解析:命令行 override → `config.toml` 根级 `sqlite_home` → `CODEX_SQLITE_HOME` → `/sqlite`。只有最后一种默认布局会继续检查旧路径 `/state_5.sqlite`;一旦显式指定 SQLite Home,就不会回退到 Codex Home 中的旧数据库。 +SQLite Home 解析顺序:`--sqlite-home` → `config.toml` 根级 `sqlite_home` → `CODEX_SQLITE_HOME` → `/sqlite`。只有默认布局会回退到 `/state_5.sqlite`。 -例如 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 在原默认位置重建数据库。 +```mermaid +flowchart LR + Browser["Browser Web UI"] --> WebServer["Local Node Web Server
127.0.0.1"] + WebServer --> NodeService["Node Service"] + CLI["Node CLI"] --> NodeService -### 自动化接口(v0.4 实验性) + WindowsGUI["Windows GUI"] --> Application[".NET Application"] + Application --> DotNetCore[".NET Core"] + MacGUI["macOS GUI"] --> DotNetCore -Release 提供独立的 Windows 自动化接口包,内含 `CodexProviderSync.Automation.exe`、`automation-protocol-v0.4.schema.json` 和中文快速说明;Windows 完整包也包含这些文件。这个一次性进程接口与 Windows GUI 共用同一套 Application 用例;每次调用只在 stdout 输出一份协议 `0.4` JSON,诊断信息写入 stderr。普通桌面用户不需要下载自动化接口包。 + NodeService --> Storage["Codex Storage"] + DotNetCore --> Storage -| 命令 | 用途 | -| --- | --- | -| `describe` | 描述协议能力和安全要求 | -| `status` | 读取状态和诊断 | -| `plan --operation sync\|switch\|restore\|prune` | 为指定写操作创建计划 | -| `sync` | 规划或显式执行同步 | -| `switch` | 规划或显式执行 Provider/model 切换与同步 | -| `restore` | 规划或显式执行备份恢复 | -| `prune` | 规划或显式清理托管备份 | - -所有写命令默认都是 dry-run,只返回计划,不会修改目标。实际写入必须同时提供 `--apply`、只包含 `plan` 响应中 `data` 对象的计划文件,以及该对象的精确小写 SHA-256 `digest`: - -```powershell -.\CodexProviderSync.Automation.exe describe -.\CodexProviderSync.Automation.exe status --codex-home C:\isolated\.codex -.\CodexProviderSync.Automation.exe sync --codex-home C:\isolated\.codex --provider openai -$planResponse = .\CodexProviderSync.Automation.exe plan --operation sync --codex-home C:\isolated\.codex --provider openai | ConvertFrom-Json -$planResponse.data | ConvertTo-Json -Depth 100 -Compress | Set-Content -LiteralPath C:\isolated\sync-plan.json -Encoding utf8NoBOM -$planDigest = $planResponse.data.digest -.\CodexProviderSync.Automation.exe sync --codex-home C:\isolated\.codex --provider openai --apply --plan C:\isolated\sync-plan.json --plan-digest $planDigest + Storage --> Config["config.toml"] + Storage --> Rollouts["sessions / archived_sessions"] + Storage --> SQLite["state_5.sqlite"] + Storage --> Backups["managed backups"] ``` -计划有有效期、绑定规范化输入和目标状态,并由持久化 ledger 保证只能使用一次;默认 ledger 位于 `\tmp\provider-sync-automation-ledger`。所有路径参数必须是绝对路径,不能穿过符号链接或 reparse point,Automation 也拒绝直接指向或访问 `auth.json`。协议仍处于 pre-1.0 实验阶段,`0.4` 之外不承诺兼容。 - -中文分步示例见 [自动化接口快速开始](docs/AUTOMATION_QUICKSTART_ZH.md)。 +- Web UI 和 CLI 使用同一套 Node 服务逻辑。 +- Windows GUI 通过 Application 层调用 .NET Core;macOS GUI 当前直接调用 .NET Core。 +- Node 服务和 .NET Core 处理相同的配置、rollout、SQLite 和备份安全边界。 -## 安全与限制 +## 安全边界 -每次 `sync` / `switch` 前都会备份到: - -```text -~/.codex/backups_state/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` 诊断;本工具不会修改时间戳来绕过此限制。 +- 每次 `sync` / `switch` 前备份到 `/backups_state/provider-sync/`;使用默认 Codex Home 时即为 `~/.codex/backups_state/provider-sync/`。 +- 不修改消息正文、会话标题、认证信息、`auth.json` 或 `updated_at`。 +- SQLite 被占用时,请关闭 Codex、Codex App 和 app-server 后重试。 +- 活跃会话锁住 rollout 时,其余文件继续处理;结束会话后再次同步即可。 +- 跨 Provider/account 继续旧会话时,目标后端可能无法解密 `encrypted_content`,导致继续对话或 compact 失败;遇到这种情况请切回原 Provider/account,或新建会话。 +- Windows 不能直接写入 WSL UNC SQLite Home;请进入 WSL 并使用 Linux 路径运行 CLI。 ## 文档 -- [Windows GUI 说明](docs/README_GUI_ZH.md) -- macOS GUI 说明:[中文](docs/README_MAC_GUI_ZH.md) · [English](docs/README_MAC_GUI_EN.md) -- [v0.4.0 中文发布说明](docs/release-notes/v0.4.0-zh.md) -- [v0.4.0 技术发布说明](docs/RELEASE_NOTES_V0.4.0.md) -- [更新日志](CHANGELOG.md) -- [自动化接口快速开始](docs/AUTOMATION_QUICKSTART_ZH.md) -- [v0.4 Automation 执行计划](docs/V0.4_AUTOMATION_PLAN.md) -- [English documentation](docs/README_EN.md) - [AI / Agent 操作指南](AGENTS.md) -- [贡献指南](CONTRIBUTING.md) +- [Windows GUI](docs/README_GUI_ZH.md) +- [Web UI](docs/README_WEB_UI_ZH.md) +- [English](docs/README_EN.md) · [日本語](docs/README_JA.md) · [한국어](docs/README_KO.md) +- [macOS GUI:中文](docs/README_MAC_GUI_ZH.md) · [English](docs/README_MAC_GUI_EN.md) +- [工作原理](docs/WORKING_PRINCIPLE_ZH.md) · [更新日志](CHANGELOG.md) · [贡献指南](CONTRIBUTING.md) ## 开发 ```bash -git clone https://github.com/Dailin521/codex-provider-sync.git -cd codex-provider-sync +npm ci +npm run web:build +npm run web:start npm test dotnet test desktop/CodexProviderSync.Core.Tests/CodexProviderSync.Core.Tests.csproj -./scripts/test-wsl-unc-safety.sh -pwsh ./scripts/publish-gui.ps1 -pwsh ./scripts/run-windows-gui-e2e.ps1 -./scripts/publish-gui-macos.sh ``` -`test-wsl-unc-safety.sh` 需要从 WSL 运行,并调用 Windows `dotnet.exe` 验证真实 WSL ext4 SQLite 的安全阻断。`run-windows-gui-e2e.ps1` 必须在可见、可交互的 Windows 桌面中运行。v0.4 的实现提交 `7545b5d` 已通过该 gate:40/40 manifest 入口覆盖、53/53 必需场景通过、0 error、0 blocker,且发布 EXE 哈希、真实控件事件、原生对话框、文件/SQLite 差异、重启持久化和 GUI → Application trace 均由证据门禁核验。后续若修改相关实现必须重新运行;隐藏、跳过或直接调用 Application 不能替代真实 GUI PASS。 +npm 包发布维护流程见 [npm 发布维护指南](docs/NPM_PUBLISHING.md)。CLI/Web 包可以独立发布,不要求同步创建 Windows GUI Release。 + +## 致谢 + +感谢 [@tangquanwei](https://github.com/tangquanwei) 提出并实现本地 Web UI,贡献聊天记录浏览和多语言文档基础,并通过 [PR #80](https://github.com/Dailin521/codex-provider-sync/pull/80) 将其带入 v0.5.0;也感谢所有参与代码、文档、测试和问题调查的贡献者。 + +[贡献者名单](CONTRIBUTORS.md) · [GitHub Contributors](https://github.com/Dailin521/codex-provider-sync/graphs/contributors) ## License diff --git a/desktop/CodexProviderSync.App.Tests/MainFormPresentationTests.cs b/desktop/CodexProviderSync.App.Tests/MainFormPresentationTests.cs index c86c95c..2139a85 100644 --- a/desktop/CodexProviderSync.App.Tests/MainFormPresentationTests.cs +++ b/desktop/CodexProviderSync.App.Tests/MainFormPresentationTests.cs @@ -226,6 +226,70 @@ private static void PerformLayoutRecursively(Control control) } } + [Fact] + public void OpenBackupFolder_ReportsNoError_WhenShellReusesAnExistingProcess() + { + string root = Path.Combine(Path.GetTempPath(), $"codex-provider-ui-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + try + { + // Explorer commonly satisfies a folder request through a window it + // already owns and then returns no Process handle. The boundary must + // treat that as a successful open, not a failure. + RecordingPlatformBoundary boundary = new(); + using MainForm form = new(new ExecutionLogService(root), platformBoundary: boundary); + SetField(form, "_currentStatus", StatusWithBackupRoot(root)); + + Invoke(form, "OpenBackupFolder"); + + Assert.Equal(root, Assert.Single(boundary.OpenedPaths)); + Assert.DoesNotContain("打开备份目录失败", Field(form, "_logBox").Text); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + // The failure branch of OpenBackupFolder is deliberately not covered here: + // its catch reports through MessageBox.Show, which has no injection seam in + // MainForm, so a test driving it would block the runner on a modal dialog. + + private static StatusSnapshot StatusWithBackupRoot(string backupRoot) => new() + { + CodexHome = @"C:\Users\user\.codex", + SqliteAccess = new SqliteAccessInfo(true, "windows", null), + CurrentProvider = new CurrentProviderInfo("openai", false), + ConfiguredProviders = ["openai"], + RolloutCounts = new ProviderCounts(), + LockedRolloutFiles = [], + UnreadableRolloutFiles = [], + EncryptedContentCounts = new ProviderCounts(), + SqliteCounts = null, + BackupRoot = backupRoot, + BackupSummary = new BackupSummary { Count = 0, TotalBytes = 0 } + }; + + private sealed class RecordingPlatformBoundary : IAppPlatformBoundary + { + public List OpenedPaths { get; } = []; + + public bool UpdatesEnabled => false; + + public string? PickFolder(IWin32Window owner, FolderPickerRequest request) => null; + + public Task CheckForUpdateAsync( + UpdateService updateService, + Version currentVersion, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public void OpenPath(string path) => OpenedPaths.Add(path); + + public void StartUpdate(string downloadedExePath, string targetExePath, string expectedSha256) => + throw new NotSupportedException(); + } + private static T Field(MainForm form, string name) where T : class { return typeof(MainForm) diff --git a/desktop/CodexProviderSync.App/AppPaths.cs b/desktop/CodexProviderSync.App/AppPaths.cs index 8110bb7..120ac73 100644 --- a/desktop/CodexProviderSync.App/AppPaths.cs +++ b/desktop/CodexProviderSync.App/AppPaths.cs @@ -227,11 +227,16 @@ public Task CheckForUpdateAsync( public void OpenPath(string path) { + // With UseShellExecute the shell may satisfy the request through a + // process it already owns - Explorer reusing an open window is the + // common case - and then returns no Process handle even though the + // path opened. A null result therefore carries no failure information; + // a genuine failure such as a missing path surfaces as Win32Exception. _ = Process.Start(new ProcessStartInfo { FileName = path, UseShellExecute = true - }) ?? throw new InvalidOperationException($"Unable to open {path}."); + }); } public void StartUpdate(string downloadedExePath, string targetExePath, string expectedSha256) => diff --git a/desktop/CodexProviderSync.App/CodexProviderSync.App.csproj b/desktop/CodexProviderSync.App/CodexProviderSync.App.csproj index 1333730..523285c 100644 --- a/desktop/CodexProviderSync.App/CodexProviderSync.App.csproj +++ b/desktop/CodexProviderSync.App/CodexProviderSync.App.csproj @@ -24,9 +24,9 @@ CodexProviderSync Codex Provider Sync Dailin521 - 0.4.0 - 0.4.0.0 - 0.4.0.0 + 0.5.0 + 0.5.0.0 + 0.5.0.0 diff --git a/desktop/CodexProviderSync.App/MainForm.cs b/desktop/CodexProviderSync.App/MainForm.cs index d0bdf16..5ce74ee 100644 --- a/desktop/CodexProviderSync.App/MainForm.cs +++ b/desktop/CodexProviderSync.App/MainForm.cs @@ -940,6 +940,9 @@ await RunBusyAsync("执行中...", async () => result, request is SwitchProviderRequest ? "已切换并同步" : "已同步", TextFormatter.ChineseSimplified)); + AppendLog(TextFormatter.FormatPerformanceMetrics( + result.PerformanceMetrics, + TextFormatter.ChineseSimplified)); AppendLog(FormatModelSyncOutcome(result.ModelSync)); AppendLog(string.Empty); await RefreshStatusCoreAsync(request.CodexHome, request.SqliteHomeOverride, provider); @@ -1076,8 +1079,16 @@ private void OpenBackupFolder() { string path = _currentStatus?.BackupRoot ?? AppConstants.DefaultBackupRoot(CurrentCodexHome()); EnsureAutomationPath(path, GuiAutomationCatalog.Ids.OpenBackupDirectory); - Directory.CreateDirectory(path); - _platformBoundary.OpenPath(path); + try + { + Directory.CreateDirectory(path); + _platformBoundary.OpenPath(path); + } + catch (Exception error) + { + AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] 打开备份目录失败: {error}"); + MessageBox.Show(this, $"无法打开备份目录。{Environment.NewLine}{Environment.NewLine}{error.Message}", Text, MessageBoxButtons.OK, MessageBoxIcon.Error); + } } private void OpenLogFolder() @@ -1591,6 +1602,9 @@ private async Task ApplyControllerRefreshAsync(AppSnapshot snapshot) ReloadProviderList(); _codexHomeCombo.Text = _currentStatus.CodexHome; AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] 已刷新: {_currentStatus.CodexHome}"); + AppendLog(TextFormatter.FormatPerformanceMetrics( + _currentStatus.PerformanceMetrics, + TextFormatter.ChineseSimplified)); } private WindowBoundsState CaptureWindowBounds() diff --git a/desktop/CodexProviderSync.Application/CodexProviderSync.Application.csproj b/desktop/CodexProviderSync.Application/CodexProviderSync.Application.csproj index 5b3658a..446ef02 100644 --- a/desktop/CodexProviderSync.Application/CodexProviderSync.Application.csproj +++ b/desktop/CodexProviderSync.Application/CodexProviderSync.Application.csproj @@ -5,9 +5,9 @@ enable enable false - 0.4.0 - 0.4.0.0 - 0.4.0.0 + 0.5.0 + 0.5.0.0 + 0.5.0.0 diff --git a/desktop/CodexProviderSync.Automation/CodexProviderSync.Automation.csproj b/desktop/CodexProviderSync.Automation/CodexProviderSync.Automation.csproj index 44aa135..bbdbc8e 100644 --- a/desktop/CodexProviderSync.Automation/CodexProviderSync.Automation.csproj +++ b/desktop/CodexProviderSync.Automation/CodexProviderSync.Automation.csproj @@ -7,9 +7,9 @@ enable CodexProviderSync.Automation CodexProviderSync.Automation - 0.4.0 - 0.4.0.0 - 0.4.0.0 + 0.5.0 + 0.5.0.0 + 0.5.0.0 diff --git a/desktop/CodexProviderSync.Core.Tests/BackupParityTests.cs b/desktop/CodexProviderSync.Core.Tests/BackupParityTests.cs index b431a96..51cd17f 100644 --- a/desktop/CodexProviderSync.Core.Tests/BackupParityTests.cs +++ b/desktop/CodexProviderSync.Core.Tests/BackupParityTests.cs @@ -36,6 +36,12 @@ await File.WriteAllTextAsync( Assert.False(globalStateFiles.GetProperty(AppConstants.GlobalStateBackupFileBasename).GetBoolean()); Assert.True(metadataRoot.GetProperty("globalStateFilePresent").GetBoolean()); Assert.False(metadataRoot.GetProperty("globalStateBackupFilePresent").GetBoolean()); + Assert.True(metadataRoot.GetProperty("sizeBytes").GetInt64() > 0); + Assert.True(metadataRoot.GetProperty("fileCount").GetInt32() > 0); + Assert.Equal( + Directory.EnumerateFiles(backupDir, "*", SearchOption.AllDirectories) + .Sum(static path => new FileInfo(path).Length), + metadataRoot.GetProperty("sizeBytes").GetInt64()); using JsonDocument manifest = JsonDocument.Parse( await File.ReadAllTextAsync(Path.Combine(backupDir, "session-meta-backup.json"))); @@ -48,6 +54,79 @@ await File.WriteAllTextAsync( entry.GetProperty("originalLastWriteTimeUtcTicks").GetString()); } + [Fact] + public async Task BackupSummary_UsesCachedInventoryAndFallsBackForLegacyMetadata() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + string cached = fixture.BackupPath("20260708T091011111Z"); + string legacy = fixture.BackupPath("20260708T091011110Z"); + Directory.CreateDirectory(cached); + Directory.CreateDirectory(legacy); + await File.WriteAllTextAsync( + Path.Combine(cached, "metadata.json"), + JsonSerializer.Serialize(new + { + version = 2, + @namespace = AppConstants.BackupNamespace, + codexHome = fixture.CodexHome, + targetProvider = "openai", + createdAt = DateTimeOffset.UtcNow, + dbFiles = Array.Empty(), + changedSessionFiles = 0, + sizeBytes = 123L, + fileCount = 1 + })); + await File.WriteAllTextAsync(Path.Combine(cached, "added-later.bin"), new string('x', 4096)); + await File.WriteAllTextAsync( + Path.Combine(legacy, "metadata.json"), + JsonSerializer.Serialize(new + { + version = 1, + @namespace = AppConstants.BackupNamespace, + codexHome = fixture.CodexHome, + targetProvider = "openai", + createdAt = DateTimeOffset.UtcNow, + dbFiles = Array.Empty(), + changedSessionFiles = 0 + })); + await File.WriteAllTextAsync(Path.Combine(legacy, "payload.bin"), new string('y', 17)); + + List fallbacks = []; + BackupService backups = new(new SessionRolloutService(), new SqliteStateService()) + { + DirectoryInventoryFallbackObserver = fallbacks.Add + }; + BackupSummary summary = await backups.GetBackupSummaryAsync(fixture.CodexHome); + + long legacyBytes = Directory.EnumerateFiles(legacy, "*", SearchOption.AllDirectories) + .Sum(static path => new FileInfo(path).Length); + Assert.Equal(2, summary.Count); + Assert.Equal(123L + legacyBytes, summary.TotalBytes); + Assert.Single(fallbacks); + Assert.Equal(Path.GetFullPath(legacy), Path.GetFullPath(fallbacks[0])); + } + + [Fact] + public async Task IncompleteMetadata_IsNotManagedOrPrunedEvenWithProviderSyncNamespace() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + string incomplete = fixture.BackupPath("20260708T091011109Z"); + Directory.CreateDirectory(incomplete); + string sentinel = Path.Combine(incomplete, "keep.txt"); + await File.WriteAllTextAsync( + Path.Combine(incomplete, "metadata.json"), + "{\"namespace\":\"provider-sync\",\"sizeBytes\":1,\"fileCount\":1}"); + await File.WriteAllTextAsync(sentinel, "must remain"); + + BackupService backups = new(new SessionRolloutService(), new SqliteStateService()); + BackupSummary summary = await backups.GetBackupSummaryAsync(fixture.CodexHome); + BackupPruneResult pruned = await backups.PruneBackupsAsync(fixture.CodexHome, 0); + + Assert.Equal(0, summary.Count); + Assert.Equal(0, pruned.DeletedCount); + Assert.True(File.Exists(sentinel)); + } + [Fact] public async Task RestoreBackup_AcceptsNodeStyleV2MetadataAndMillisecondSessionTimestamp() { diff --git a/desktop/CodexProviderSync.Core.Tests/CoreIntegrationTests.cs b/desktop/CodexProviderSync.Core.Tests/CoreIntegrationTests.cs index da14a9f..8e21897 100644 --- a/desktop/CodexProviderSync.Core.Tests/CoreIntegrationTests.cs +++ b/desktop/CodexProviderSync.Core.Tests/CoreIntegrationTests.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; using System.Diagnostics; using Microsoft.Data.Sqlite; @@ -45,6 +46,57 @@ await fixture.WriteStateDbAsync([ Assert.Equal("apigather", await ReadProviderAsync(fixture.StateDbPath(), "thread-a")); Assert.Equal("apigather", await ReadProviderAsync(fixture.StateDbPath(), "thread-b")); Assert.Empty(await FileTransactionJournal.FindPendingAsync(fixture.CodexHome)); + + // The rollback appended more journal records after the inventory was + // written, so the retained backup must record its real size. Status and + // pruning read these cached values without revalidating them. + AssertBackupInventoryMatchesDisk(error.BackupDirectory); + } + + [Fact] + public async Task RunSync_RefreshesRetainedBackupInventory_WhenRollbackCompletes() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + string sessionPath = fixture.RolloutPath("sessions", "rollout-a.jsonl"); + await fixture.WriteRolloutAsync(sessionPath, "thread-a", "apigather"); + await fixture.WriteStateDbAsync([("thread-a", "apigather", false)]); + + // Fail the apply so the rollback path runs and the journal appends its + // terminal records after metadata.json was already written. + CodexSyncService service = new(); + service.FaultInjector = (point, _, _) => + { + if (point == "before_rollout_apply") + { + throw new IOException("injected apply failure"); + } + return Task.CompletedTask; + }; + + SyncTransactionException error = await Assert.ThrowsAsync( + () => service.RunSyncAsync(fixture.CodexHome, provider: "openai")); + + // Pin the path this covers: the rollback succeeded, so the journal wrote + // its rolledBack record into the retained backup after the inventory was + // captured. The recorded size and file count must still match disk. + Assert.Equal("complete", error.RollbackStatus); + Assert.False(error.RecoveryRequired); + AssertBackupInventoryMatchesDisk(error.BackupDirectory); + } + + private static void AssertBackupInventoryMatchesDisk(string backupDirectory) + { + string metadataPath = Path.Combine(backupDirectory, "metadata.json"); + using JsonDocument document = JsonDocument.Parse(File.ReadAllText(metadataPath)); + long recordedSize = document.RootElement.GetProperty("sizeBytes").GetInt64(); + int recordedCount = document.RootElement.GetProperty("fileCount").GetInt32(); + + string[] actualFiles = Directory.GetFiles(backupDirectory, "*", SearchOption.AllDirectories); + long actualSize = actualFiles.Sum(file => new FileInfo(file).Length); + + Assert.Equal(actualFiles.Length, recordedCount); + Assert.Equal(actualSize, recordedSize); } [Fact] @@ -960,9 +1012,21 @@ await fixture.WriteStateDbAsync( Assert.Empty(syncResult.SkippedLockedRolloutFiles); Assert.Empty(syncResult.SkippedUnreadableRolloutFiles); Assert.Equal(2, syncResult.SqliteRowsUpdated); + Assert.Equal(2, syncResult.PerformanceMetrics.RolloutScan.EnumeratedRolloutFiles); + Assert.Equal(2, syncResult.PerformanceMetrics.RolloutScan.ContentScanPasses); + Assert.Equal( + OperatingSystem.IsWindows() ? 2 : 8, + syncResult.PerformanceMetrics.JournalFullValidationCount); BackupMetadataFile backupMetadata = JsonSerializer.Deserialize( await File.ReadAllTextAsync(Path.Combine(syncResult.BackupDir, "metadata.json")), new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })!; + Assert.Equal( + Directory.EnumerateFiles(syncResult.BackupDir, "*", SearchOption.AllDirectories) + .Sum(static path => new FileInfo(path).Length), + backupMetadata.SizeBytes); + Assert.Equal( + Directory.EnumerateFiles(syncResult.BackupDir, "*", SearchOption.AllDirectories).Count(), + backupMetadata.FileCount); Assert.Equal( [ Path.Combine(AppConstants.SqliteDirBasename, AppConstants.DbFileBasename) @@ -1343,6 +1407,8 @@ await fixture.WriteStateDbAsync( Assert.Equal(fixture.StateDbPath(), status.StateDbLocation.Path); Assert.Equal(2, status.BackupSummary.Count); Assert.Equal(backupOneBytes + backupTwoBytes, status.BackupSummary.TotalBytes); + Assert.Equal(2, status.PerformanceMetrics.RolloutScan.EnumeratedRolloutFiles); + Assert.Equal(2, status.PerformanceMetrics.RolloutScan.ContentScanPasses); Assert.Contains($"database: {fixture.StateDbPath()}", TextFormatter.FormatStatus(status)); } @@ -1580,6 +1646,67 @@ await File.AppendAllTextAsync( Assert.Contains("\"message\":\"later\"", rollout); } + [Fact] + public async Task CollectSessionChanges_ReportsLockedInsteadOfAborting_WhenRolloutChangesDuringScan() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + string movingPath = fixture.RolloutPath("sessions", "rollout-a.jsonl"); + string stablePath = fixture.RolloutPath("sessions", "rollout-b.jsonl"); + await fixture.WriteRolloutAsync(movingPath, "thread-a", "apigather"); + await fixture.WriteRolloutAsync(stablePath, "thread-b", "apigather"); + + // Emulate an active Codex session appending to its own rollout inside the + // window between the digest fold and the post-scan snapshot. + SessionRolloutService service = new() + { + ScanFaultInjector = async scannedPath => + { + if (string.Equals(scannedPath, movingPath, StringComparison.Ordinal)) + { + await File.AppendAllTextAsync( + scannedPath, + "{\"timestamp\":\"2026-03-19T00:00:01.000Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"assistant_message\",\"message\":\"live\"}}\n"); + } + } + }; + + SessionChangeCollection collected = await service.CollectSessionChangesAsync( + fixture.CodexHome, + "openai", + skipLockedReads: true); + + Assert.Contains(movingPath, collected.LockedPaths); + Assert.DoesNotContain(movingPath, collected.Changes.Select(static change => change.Path)); + Assert.Contains(stablePath, collected.Changes.Select(static change => change.Path)); + + // The rest of the corpus is still rewritten; only the live rollout waits. + SessionApplyResult applyResult = await service.ApplySessionChangesAsync(collected.Changes); + Assert.Equal(1, applyResult.AppliedCount); + Assert.Empty(applyResult.SkippedPaths); + Assert.Contains("\"model_provider\":\"apigather\"", await File.ReadAllTextAsync(movingPath)); + Assert.Contains("\"model_provider\":\"openai\"", await File.ReadAllTextAsync(stablePath)); + } + + [Fact] + public async Task CollectSessionChanges_Throws_WhenRolloutChangesDuringScanWithoutSkipLockedReads() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + string movingPath = fixture.RolloutPath("sessions", "rollout-a.jsonl"); + await fixture.WriteRolloutAsync(movingPath, "thread-a", "apigather"); + + SessionRolloutService service = new() + { + ScanFaultInjector = async scannedPath => await File.AppendAllTextAsync( + scannedPath, + "{\"timestamp\":\"2026-03-19T00:00:01.000Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"assistant_message\",\"message\":\"live\"}}\n") + }; + + await Assert.ThrowsAsync(() => + service.CollectSessionChangesAsync(fixture.CodexHome, "openai", skipLockedReads: false)); + } + [Fact] public async Task ApplySessionChanges_RewritesFile_WhenRolloutIsUnchanged() { @@ -1799,6 +1926,58 @@ await fixture.WriteBackupAsync( Assert.True(string.IsNullOrWhiteSpace(result.AutoPruneWarning)); } + [Fact] + public async Task RunSync_SucceedsWithWarningAndStillPrunes_WhenInventoryRefreshFailsAfterCommit() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + string sessionPath = fixture.RolloutPath("sessions", "rollout-a.jsonl"); + await fixture.WriteRolloutAsync(sessionPath, "thread-a", "apigather"); + await fixture.WriteStateDbAsync([("thread-a", "apigather", false)]); + + for (int index = 0; index < AppConstants.DefaultBackupRetentionCount; index += 1) + { + await fixture.WriteBackupAsync( + $"20240101T0000{index:00}000Z", + ("note.txt", $"backup-{index}")); + } + + // Break the inventory refresh that runs immediately after the journal + // commit. The transaction is durable by then, so the sync must report + // success with a warning rather than failing. + CodexSyncService service = new(); + service.FaultInjector = (point, _, _) => + { + if (point == "before_transaction_commit") + { + // The backup directory this transaction owns is the only one + // still carrying a journal file. Keep the namespace so it stays a + // managed backup for the prune pass, but make the version + // unreadable so only the inventory refresh fails. + string activeBackupDir = Directory + .GetDirectories(fixture.BackupRoot()) + .Single(dir => File.Exists(Path.Combine(dir, FileTransactionJournal.FileName))); + string metadataPath = Path.Combine(activeBackupDir, "metadata.json"); + JsonNode metadata = JsonNode.Parse(File.ReadAllText(metadataPath))!; + metadata["version"] = 99; + File.WriteAllText(metadataPath, metadata.ToJsonString()); + } + + return Task.CompletedTask; + }; + + SyncResult result = await service.RunSyncAsync(fixture.CodexHome); + + Assert.NotNull(result.AutoPruneResult); + Assert.Equal(1, result.AutoPruneResult!.DeletedCount); + Assert.Contains("Backup inventory refresh failed", result.AutoPruneWarning); + + // The mutation itself still landed. + Assert.Contains("\"model_provider\":\"openai\"", await File.ReadAllTextAsync(sessionPath)); + Assert.Equal("openai", await ReadProviderAsync(fixture.StateDbPath(), "thread-a")); + Assert.Empty(await FileTransactionJournal.FindPendingAsync(fixture.CodexHome)); + } + [Fact] public async Task RunSync_UsesCustomAutomaticBackupRetentionCount() { diff --git a/desktop/CodexProviderSync.Core.Tests/CoreWritePlanningTests.cs b/desktop/CodexProviderSync.Core.Tests/CoreWritePlanningTests.cs index 16c48d4..9e8a3b9 100644 --- a/desktop/CodexProviderSync.Core.Tests/CoreWritePlanningTests.cs +++ b/desktop/CodexProviderSync.Core.Tests/CoreWritePlanningTests.cs @@ -5,6 +5,45 @@ namespace CodexProviderSync.Core.Tests; public sealed class CoreWritePlanningTests { + [Fact] + public async Task ContentFingerprintHint_ReusesExactDigestForPlanPreview() + { + string root = Path.Combine(Path.GetTempPath(), $"codex-provider-hint-{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + string path = Path.Combine(root, "rollout.jsonl"); + await File.WriteAllTextAsync(path, "{\"type\":\"session_meta\"}\n"); + try + { + FileInfo info = new(path); + string digest = "sha256:" + Convert.ToHexString( + System.Security.Cryptography.SHA256.HashData(await File.ReadAllBytesAsync(path))) + .ToLowerInvariant() + + $":{info.Length}:{info.LastWriteTimeUtc.Ticks}"; + int hintedReads = 0; + CoreWritePlanSnapshot hinted = await CoreWriteSnapshotBuilder.BuildAsync( + "sync", + "hinted", + [new CoreWriteTargetSpec(path, "replace")], + contentFingerprintHints: + [ + new CoreWriteContentFingerprintHint( + path, + digest, + info.Length, + info.LastWriteTimeUtc.Ticks) + ], + fingerprintObserver: _ => hintedReads += 1); + + CoreWritePlanTarget target = Assert.Single(hinted.Targets); + Assert.Equal(digest, target.Fingerprint); + Assert.Equal(0, hintedReads); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + [Fact] public async Task CheckedSync_RejectsDriftBeforeBackupOrMutation() { @@ -524,6 +563,39 @@ public async Task SqliteWalFingerprint_TreatsMissingAndZeroLengthAsEquivalent() CoreWriteSnapshotBuilder.AssertExactMatch(empty, missingAgain); } + [Fact] + public async Task SnapshotBuilder_ReusesRecursiveInventoryAcrossMainAndAutoPruneTargets() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + string backupRoot = Path.Combine(fixture.Root, "backups"); + string candidate = Path.Combine(backupRoot, "20260807T000000000Z"); + string payload = Path.Combine(candidate, "metadata.json"); + Directory.CreateDirectory(candidate); + await File.WriteAllTextAsync(payload, "{}"); + List fingerprintedPaths = []; + + CoreWritePlanSnapshot snapshot = await CoreWriteSnapshotBuilder.BuildAsync( + "sync", + "shared-recursive-inventory", + [new CoreWriteTargetSpec( + backupRoot, + "create-and-prune", + CoreWriteFingerprintMode.RecursiveInventory)], + [new CoreWriteTargetSpec( + candidate, + "delete", + CoreWriteFingerprintMode.RecursiveInventory)], + fingerprintObserver: fingerprintedPaths.Add); + + Assert.Single(snapshot.Targets); + Assert.Single(snapshot.AutoPruneDeletionTargets); + StringComparer comparer = OperatingSystem.IsWindows() + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + Assert.Equal(1, fingerprintedPaths.Count(path => comparer.Equals(path, Path.GetFullPath(candidate)))); + Assert.Equal(1, fingerprintedPaths.Count(path => comparer.Equals(path, Path.GetFullPath(payload)))); + } + [Fact] public async Task CheckedRestore_RejectsCommittedWalDriftBeforeMutation() { diff --git a/desktop/CodexProviderSync.Core.Tests/SessionRolloutPerformanceTests.cs b/desktop/CodexProviderSync.Core.Tests/SessionRolloutPerformanceTests.cs new file mode 100644 index 0000000..4d08f3e --- /dev/null +++ b/desktop/CodexProviderSync.Core.Tests/SessionRolloutPerformanceTests.cs @@ -0,0 +1,156 @@ +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text.Json; +using Xunit.Abstractions; + +namespace CodexProviderSync.Core.Tests; + +public sealed class SessionRolloutPerformanceTests(ITestOutputHelper output) +{ + [Fact] + public async Task ProviderChange_ContentFingerprintMatchesExactRolloutBytes() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + try + { + string path = fixture.RolloutPath("sessions", "rollout-fingerprint.jsonl"); + await fixture.WriteRolloutAsync(path, "thread-fingerprint", "apigather"); + FileInfo snapshot = new(path); + string digest = Convert.ToHexString( + SHA256.HashData(await File.ReadAllBytesAsync(path))) + .ToLowerInvariant(); + + SessionChangeCollection result = await new SessionRolloutService() + .CollectSessionChangesAsync(fixture.CodexHome, "openai"); + + SessionChange change = Assert.Single(result.Changes); + Assert.Equal( + $"sha256:{digest}:{snapshot.Length}:{snapshot.LastWriteTimeUtc.Ticks}", + change.ContentFingerprint); + } + finally + { + Directory.Delete(fixture.Root, recursive: true); + } + } + + [Fact] + public async Task CollectSessionChanges_UsesOneContentPassPerRollout() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + try + { + for (int index = 0; index < 16; index++) + { + string directory = index < 12 ? "sessions" : "archived_sessions"; + string path = fixture.RolloutPath(directory, $"rollout-{index:D2}.jsonl"); + await fixture.WriteRolloutAsync(path, $"thread-{index:D2}", "apigather"); + } + + SessionChangeCollection result = await new SessionRolloutService() + .CollectSessionChangesAsync(fixture.CodexHome, "openai"); + + Assert.Equal(16, result.ScanMetrics.EnumeratedRolloutFiles); + Assert.Equal(16, result.ScanMetrics.ParsedSessionFiles); + Assert.Equal(16, result.ScanMetrics.ContentScanPasses); + Assert.Equal(0, result.ScanMetrics.ModelScanFiles); + Assert.Equal(16, result.Changes.Count); + Assert.Equal(16, result.UserEventThreadIds.Count); + } + finally + { + Directory.Delete(fixture.Root, recursive: true); + } + } + + [Fact] + public async Task CollectSessionChanges_CollectsAllModelBackupsInTheSameContentPass() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + try + { + for (int index = 0; index < 8; index++) + { + string path = fixture.RolloutPath("sessions", $"rollout-model-{index:D2}.jsonl"); + await fixture.WriteRolloutWithTurnContextAsync( + path, + $"thread-model-{index:D2}", + "openai", + "old-model"); + } + + SessionChangeCollection result = await new SessionRolloutService() + .CollectSessionChangesAsync( + fixture.CodexHome, + "openai", + targetModel: "new-model"); + + Assert.Equal(8, result.ScanMetrics.EnumeratedRolloutFiles); + Assert.Equal(8, result.ScanMetrics.ContentScanPasses); + Assert.Equal(8, result.ScanMetrics.ModelScanFiles); + Assert.Equal(8, result.Changes.Count); + Assert.All(result.Changes, change => Assert.Equal(2, change.OriginalTurnContextModels.Count)); + } + finally + { + Directory.Delete(fixture.Root, recursive: true); + } + } + + [Fact] + [Trait("Category", "Performance")] + public async Task EightHundredRollouts_UseExactlyEightHundredContentPasses() + { + if (!string.Equals( + Environment.GetEnvironmentVariable("CODEX_PROVIDER_SYNC_RUN_PERF_TESTS"), + "1", + StringComparison.Ordinal)) + { + return; + } + + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + try + { + string sessionDirectory = Path.GetDirectoryName( + fixture.RolloutPath("sessions", "rollout-placeholder.jsonl"))!; + for (int index = 0; index < 800; index++) + { + string first = JsonSerializer.Serialize(new + { + timestamp = "2026-08-07T00:00:00.000Z", + type = "session_meta", + payload = new + { + id = $"thread-{index:D4}", + cwd = "C:\\AITemp", + model_provider = "apigather" + } + }); + string user = JsonSerializer.Serialize(new + { + type = "event_msg", + payload = new { type = "user_message", message = "hi" } + }); + await File.WriteAllTextAsync( + Path.Combine(sessionDirectory, $"rollout-{index:D4}.jsonl"), + $"{first}\n{user}\n"); + } + + Stopwatch timer = Stopwatch.StartNew(); + SessionChangeCollection result = await new SessionRolloutService() + .CollectSessionChangesAsync(fixture.CodexHome, "openai"); + timer.Stop(); + + Assert.Equal(800, result.ScanMetrics.EnumeratedRolloutFiles); + Assert.Equal(800, result.ScanMetrics.ContentScanPasses); + Assert.Equal(0, result.ScanMetrics.ModelScanFiles); + output.WriteLine( + $"rollouts=800 elapsedMs={timer.ElapsedMilliseconds} contentPasses={result.ScanMetrics.ContentScanPasses}"); + } + finally + { + Directory.Delete(fixture.Root, recursive: true); + } + } +} diff --git a/desktop/CodexProviderSync.Core.Tests/TextFormatterTests.cs b/desktop/CodexProviderSync.Core.Tests/TextFormatterTests.cs index 5d4a540..70e478c 100644 --- a/desktop/CodexProviderSync.Core.Tests/TextFormatterTests.cs +++ b/desktop/CodexProviderSync.Core.Tests/TextFormatterTests.cs @@ -145,6 +145,42 @@ public void ResultFormatters_ChineseCoverSyncRestoreAndBackupCleanup() Assert.Contains("释放空间: 2 KB", pruneText); } + [Fact] + public void PerformanceFormatters_ReportStageDurationsAndStructuralCounts() + { + SyncPerformanceMetrics sync = new() + { + TotalDurationMs = 100, + PreparationDurationMs = 20, + CheckedPlanValidationDurationMs = 15, + BackupDurationMs = 10, + MutationDurationMs = 50, + PruneDurationMs = 5, + JournalFullValidationCount = 2, + RolloutScan = new SessionScanMetrics + { + EnumeratedRolloutFiles = 800, + ContentScanPasses = 800 + } + }; + StatusPerformanceMetrics status = new() + { + TotalDurationMs = 30, + RolloutScanDurationMs = 20, + BackupSummaryDurationMs = 4, + RolloutScan = sync.RolloutScan + }; + + string syncText = TextFormatter.FormatPerformanceMetrics(sync, TextFormatter.ChineseSimplified); + string statusText = TextFormatter.FormatPerformanceMetrics(status, TextFormatter.English); + + Assert.Contains("计划校验=15ms", syncText); + Assert.Contains("内容扫描=800", syncText); + Assert.Contains("journal全量校验=2", syncText); + Assert.Contains("rollout-scan=20ms", statusText); + Assert.Contains("content-scans=800", statusText); + } + private static StatusSnapshot MinimalStatus() => new() { CodexHome = @"C:\Codex", diff --git a/desktop/CodexProviderSync.Core.Tests/TransactionJournalPerformanceTests.cs b/desktop/CodexProviderSync.Core.Tests/TransactionJournalPerformanceTests.cs new file mode 100644 index 0000000..d7990d5 --- /dev/null +++ b/desktop/CodexProviderSync.Core.Tests/TransactionJournalPerformanceTests.cs @@ -0,0 +1,64 @@ +using System.Diagnostics; +using Xunit.Abstractions; + +namespace CodexProviderSync.Core.Tests; + +public sealed class TransactionJournalPerformanceTests(ITestOutputHelper output) +{ + [Fact] + [Trait("Category", "Performance")] + public async Task EightHundredTargets_AppendWithoutGrowingFullJournalReads() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + if (!string.Equals( + Environment.GetEnvironmentVariable("CODEX_PROVIDER_SYNC_RUN_PERF_TESTS"), + "1", + StringComparison.Ordinal)) + { + return; + } + + string root = Path.Combine( + Path.GetTempPath(), + $"codex-provider-journal-perf-{Guid.NewGuid():N}"); + string backupDir = Path.Combine(root, "backup"); + string codexHome = Path.Combine(root, ".codex"); + Directory.CreateDirectory(backupDir); + Directory.CreateDirectory(codexHome); + string[] targets = Enumerable.Range(0, 800) + .Select(index => Path.Combine(codexHome, $"rollout-{index:D4}.jsonl")) + .ToArray(); + + try + { + await using FileTransactionJournal journal = await FileTransactionJournal.CreateOwnedAsync( + backupDir, + codexHome, + "target-provider", + targets); + Stopwatch timer = Stopwatch.StartNew(); + foreach (string target in targets) + { + await journal.ApplyingAsync("rollout", target); + await journal.AppliedAsync("rollout", target); + } + timer.Stop(); + + Assert.Equal(0, journal.AppendFullJournalValidationCount); + await journal.CommittedAsync(); + Assert.Equal(2, journal.AppendFullJournalValidationCount); + output.WriteLine( + $"targets=800 appends=1600 elapsedMs={timer.ElapsedMilliseconds} fullJournalReads={journal.AppendFullJournalValidationCount}"); + } + finally + { + if (Directory.Exists(root)) + { + Directory.Delete(root, recursive: true); + } + } + } +} diff --git a/desktop/CodexProviderSync.Core.Tests/TransactionJournalTests.cs b/desktop/CodexProviderSync.Core.Tests/TransactionJournalTests.cs index 4ab78f0..9a1578d 100644 --- a/desktop/CodexProviderSync.Core.Tests/TransactionJournalTests.cs +++ b/desktop/CodexProviderSync.Core.Tests/TransactionJournalTests.cs @@ -82,6 +82,90 @@ public async Task AppendAsync_ResynchronizesFromValidExternallyAppendedRecord() Assert.Equal(3, applied.LastSequence); Assert.Equal("applied", applied.State); Assert.False(applied.InvalidTail); + Assert.Equal(1, fixture.Journal.AppendFullJournalValidationCount); + } + + [Fact] + public async Task NormalProgressAppends_DoNotReparseGrowingJournal() + { + JournalFixture fixture = await JournalFixture.CreateAsync(32, owned: true); + + foreach (string target in fixture.Targets) + { + await fixture.Journal.ApplyingAsync("rollout", target); + await fixture.Journal.AppliedAsync("rollout", target); + } + + Assert.Equal( + OperatingSystem.IsWindows() ? 0 : 64, + fixture.Journal.AppendFullJournalValidationCount); + await fixture.Journal.CommittedAsync(); + Assert.Equal( + OperatingSystem.IsWindows() ? 2 : 66, + fixture.Journal.AppendFullJournalValidationCount); + + PendingTransactionInfo committed = await fixture.Journal.ReadCurrentInfoAsync(); + Assert.True(committed.Terminal); + Assert.Equal(66, committed.LastSequence); + Assert.All(committed.AffectedTargets, target => Assert.Equal("applied", target.State)); + } + + [Fact] + public async Task AppendAsync_RejectsSameLengthInvalidTailBeforeWriting() + { + JournalFixture fixture = await JournalFixture.CreateAsync(1); + byte[] before = await File.ReadAllBytesAsync(fixture.Journal.FilePath); + Assert.Equal((byte)'\n', before[^1]); + before[^1] = (byte)' '; + await File.WriteAllBytesAsync(fixture.Journal.FilePath, before); + long invalidLength = new FileInfo(fixture.Journal.FilePath).Length; + + InvalidOperationException error = await Assert.ThrowsAsync( + () => fixture.Journal.ApplyingAsync("rollout", fixture.Targets[0])); + + Assert.Contains("invalid", error.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(invalidLength, new FileInfo(fixture.Journal.FilePath).Length); + Assert.Equal(1, fixture.Journal.AppendFullJournalValidationCount); + Assert.True((await fixture.Journal.ReadCurrentInfoAsync()).InvalidTail); + } + + [Fact] + public async Task AppendAsync_RejectsSameLengthPreparedRewriteBeforeWriting() + { + JournalFixture fixture = await JournalFixture.CreateAsync(1); + string originalTarget = Path.GetFullPath(fixture.Targets[0]); + string before = await File.ReadAllTextAsync(fixture.Journal.FilePath); + string rewritten = before.Replace("rollout-0.jsonl", "rollout-0.jsonx", StringComparison.Ordinal); + Assert.Equal(before.Length, rewritten.Length); + Assert.NotEqual(before, rewritten); + await File.WriteAllTextAsync(fixture.Journal.FilePath, rewritten); + long rewrittenLength = new FileInfo(fixture.Journal.FilePath).Length; + + InvalidOperationException error = await Assert.ThrowsAsync( + () => fixture.Journal.ApplyingAsync("rollout", originalTarget)); + + Assert.Contains("prepared", error.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(rewrittenLength, new FileInfo(fixture.Journal.FilePath).Length); + } + + [Fact] + public async Task OwnedJournal_BlocksExternalSameLengthRewriteOnWindows() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + JournalFixture fixture = await JournalFixture.CreateAsync(1, owned: true); + await Assert.ThrowsAsync( + async () => + { + await using FileStream _ = new( + fixture.Journal.FilePath, + FileMode.Open, + FileAccess.Write, + FileShare.ReadWrite | FileShare.Delete); + }); + await fixture.Journal.ApplyingAsync("rollout", fixture.Targets[0]); } [Fact] @@ -185,7 +269,7 @@ private sealed record JournalFixture( FileTransactionJournal Journal, IReadOnlyList Targets) { - internal static async Task CreateAsync(int targetCount) + internal static async Task CreateAsync(int targetCount, bool owned = false) { string root = Path.Combine( Path.GetTempPath(), @@ -197,11 +281,17 @@ internal static async Task CreateAsync(int targetCount) string[] targets = Enumerable.Range(0, targetCount) .Select(index => Path.Combine(codexHome, $"rollout-{index}.jsonl")) .ToArray(); - FileTransactionJournal journal = await FileTransactionJournal.CreateAsync( - backupDir, - codexHome, - "target-provider", - targets); + FileTransactionJournal journal = owned + ? await FileTransactionJournal.CreateOwnedAsync( + backupDir, + codexHome, + "target-provider", + targets) + : await FileTransactionJournal.CreateAsync( + backupDir, + codexHome, + "target-provider", + targets); return new JournalFixture(root, journal, targets); } } diff --git a/desktop/CodexProviderSync.Core/BackupService.cs b/desktop/CodexProviderSync.Core/BackupService.cs index a12fd41..8322717 100644 --- a/desktop/CodexProviderSync.Core/BackupService.cs +++ b/desktop/CodexProviderSync.Core/BackupService.cs @@ -1,3 +1,4 @@ +using System.Text; using System.Text.Json; namespace CodexProviderSync.Core; @@ -9,6 +10,8 @@ public sealed class BackupService internal Func? AtomicWriteFaultInjector { get; set; } + internal Action? DirectoryInventoryFallbackObserver { get; set; } + public BackupService(SessionRolloutService sessionRolloutService, SqliteStateService sqliteStateService) { _sessionRolloutService = sessionRolloutService; @@ -136,10 +139,7 @@ await AtomicFile.WriteAllTextAsync( GlobalStateFilePresent = globalStateFilePresent, GlobalStateBackupFilePresent = globalStateBackupFilePresent }; - await AtomicFile.WriteAllTextAsync( - Path.Combine(backupDir, "metadata.json"), - JsonSerializer.Serialize(metadata, JsonOptions()), - faultInjector: AtomicWriteFaultInjector); + await WriteMetadataWithInventoryAsync(backupDir, metadata); return backupDir; } @@ -332,17 +332,16 @@ await File.ReadAllTextAsync(metadataPath), ChangedSessionFiles = sessionChanges.Count, GlobalStateFiles = metadata.GlobalStateFiles, GlobalStateFilePresent = metadata.GlobalStateFilePresent, - GlobalStateBackupFilePresent = metadata.GlobalStateBackupFilePresent + GlobalStateBackupFilePresent = metadata.GlobalStateBackupFilePresent, + SizeBytes = metadata.SizeBytes, + FileCount = metadata.FileCount }; await AtomicFile.WriteAllTextAsync( manifestPath, JsonSerializer.Serialize(sessionManifest, JsonOptions()), faultInjector: AtomicWriteFaultInjector); - await AtomicFile.WriteAllTextAsync( - metadataPath, - JsonSerializer.Serialize(metadata, JsonOptions()), - faultInjector: AtomicWriteFaultInjector); + await WriteMetadataWithInventoryAsync(normalizedBackupDir, metadata); } internal async Task> ReadSessionBackupEntriesAsync( @@ -472,6 +471,22 @@ await File.ReadAllTextAsync(metadataPath), }; } + internal async Task RefreshMetadataInventoryAsync(string backupDir) + { + string normalizedBackupDir = Path.GetFullPath(backupDir); + string metadataPath = Path.Combine(normalizedBackupDir, "metadata.json"); + BackupMetadataFile metadata = JsonSerializer.Deserialize( + await File.ReadAllTextAsync(metadataPath), + JsonOptions()) ?? throw new InvalidOperationException($"Backup metadata is invalid: {backupDir}"); + if (!string.Equals(metadata.Namespace, AppConstants.BackupNamespace, StringComparison.Ordinal) + || metadata.Version is not (1 or 2)) + { + throw new InvalidOperationException($"Unsupported backup metadata in {metadataPath}."); + } + + await WriteMetadataWithInventoryAsync(normalizedBackupDir, metadata); + } + public Task GetBackupSummaryAsync(string codexHome) { string backupRoot = AppConstants.DefaultBackupRoot(codexHome); @@ -487,7 +502,7 @@ public Task GetBackupSummaryAsync(string codexHome) } List entries = GetManagedBackupDirectories(backupRoot); - long totalBytes = entries.Sum(static entry => GetDirectorySize(entry.FullName)); + long totalBytes = entries.Sum(entry => GetBackupDirectorySize(entry.FullName)); return new BackupSummary { @@ -581,7 +596,7 @@ private async Task PruneBackupsCoreAsync( long freedBytes = 0; foreach (DirectoryInfo entry in toDelete) { - freedBytes += GetDirectorySize(entry.FullName); + freedBytes += GetBackupDirectorySize(entry.FullName); entry.Delete(recursive: true); } @@ -912,16 +927,127 @@ private static JsonSerializerOptions JsonOptions() }; } - private static long GetDirectorySize(string directoryPath) + private async Task WriteMetadataWithInventoryAsync(string backupDir, BackupMetadataFile metadata) + { + string metadataPath = Path.Combine(backupDir, "metadata.json"); + (long payloadBytes, int payloadFileCount) = GetDirectoryInventory( + backupDir, + metadataPath); + int fileCount = checked(payloadFileCount + 1); + long sizeBytes = 0; + string serialized = string.Empty; + for (int attempt = 0; attempt < 8; attempt += 1) + { + BackupMetadataFile withInventory = CopyMetadataWithInventory(metadata, sizeBytes, fileCount); + serialized = JsonSerializer.Serialize(withInventory, JsonOptions()); + long nextSizeBytes = checked(payloadBytes + Encoding.UTF8.GetByteCount(serialized)); + if (nextSizeBytes == sizeBytes) + { + break; + } + sizeBytes = nextSizeBytes; + } + + BackupMetadataFile finalMetadata = CopyMetadataWithInventory(metadata, sizeBytes, fileCount); + serialized = JsonSerializer.Serialize(finalMetadata, JsonOptions()); + long verifiedSizeBytes = checked(payloadBytes + Encoding.UTF8.GetByteCount(serialized)); + if (verifiedSizeBytes != sizeBytes) + { + finalMetadata = CopyMetadataWithInventory(metadata, verifiedSizeBytes, fileCount); + serialized = JsonSerializer.Serialize(finalMetadata, JsonOptions()); + } + await AtomicFile.WriteAllTextAsync( + metadataPath, + serialized, + faultInjector: AtomicWriteFaultInjector); + } + + private static BackupMetadataFile CopyMetadataWithInventory( + BackupMetadataFile metadata, + long sizeBytes, + int fileCount) + { + return new BackupMetadataFile + { + Version = metadata.Version, + Namespace = metadata.Namespace, + CodexHome = metadata.CodexHome, + SqliteHome = metadata.SqliteHome, + TargetProvider = metadata.TargetProvider, + CreatedAt = metadata.CreatedAt, + DbFiles = metadata.DbFiles, + SqliteDbFiles = metadata.SqliteDbFiles, + ChangedSessionFiles = metadata.ChangedSessionFiles, + GlobalStateFiles = metadata.GlobalStateFiles, + GlobalStateFilePresent = metadata.GlobalStateFilePresent, + GlobalStateBackupFilePresent = metadata.GlobalStateBackupFilePresent, + SizeBytes = sizeBytes, + FileCount = fileCount + }; + } + + private long GetBackupDirectorySize(string directoryPath) + { + if (TryReadCachedDirectoryInventory(directoryPath, out long sizeBytes)) + { + return sizeBytes; + } + DirectoryInventoryFallbackObserver?.Invoke(directoryPath); + return GetDirectoryInventory(directoryPath).SizeBytes; + } + + private static bool TryReadCachedDirectoryInventory(string directoryPath, out long sizeBytes) + { + sizeBytes = 0; + string metadataPath = Path.Combine(directoryPath, "metadata.json"); + try + { + using JsonDocument document = JsonDocument.Parse(File.ReadAllText(metadataPath)); + JsonElement root = document.RootElement; + if (!root.TryGetProperty("namespace", out JsonElement namespaceValue) + || !string.Equals(namespaceValue.GetString(), AppConstants.BackupNamespace, StringComparison.Ordinal) + || !root.TryGetProperty("sizeBytes", out JsonElement sizeValue) + || !sizeValue.TryGetInt64(out long cachedSize) + || cachedSize < 0 + || !root.TryGetProperty("fileCount", out JsonElement countValue) + || !countValue.TryGetInt32(out int cachedFileCount) + || cachedFileCount < 1) + { + return false; + } + sizeBytes = cachedSize; + return true; + } + catch + { + return false; + } + } + + private static (long SizeBytes, int FileCount) GetDirectoryInventory( + string directoryPath, + string? excludedFilePath = null) { if (!Directory.Exists(directoryPath)) { - return 0; + return (0, 0); } - return Directory - .EnumerateFiles(directoryPath, "*", SearchOption.AllDirectories) - .Sum(static filePath => new FileInfo(filePath).Length); + string? excluded = string.IsNullOrWhiteSpace(excludedFilePath) + ? null + : Path.GetFullPath(excludedFilePath); + long sizeBytes = 0; + int fileCount = 0; + foreach (string filePath in Directory.EnumerateFiles(directoryPath, "*", SearchOption.AllDirectories)) + { + if (excluded is not null && PathsEqual(filePath, excluded)) + { + continue; + } + sizeBytes = checked(sizeBytes + new FileInfo(filePath).Length); + fileCount = checked(fileCount + 1); + } + return (sizeBytes, fileCount); } private static List GetManagedBackupDirectories(string backupRoot) @@ -945,7 +1071,7 @@ private static bool IsManagedBackupDirectory(string backupDirectoryPath) try { - BackupMetadataFile? metadata = JsonSerializer.Deserialize( + BackupMetadataValidationFile? metadata = JsonSerializer.Deserialize( File.ReadAllText(metadataPath), JsonOptions()); return string.Equals(metadata?.Namespace, AppConstants.BackupNamespace, StringComparison.Ordinal); @@ -955,6 +1081,22 @@ private static bool IsManagedBackupDirectory(string backupDirectoryPath) return false; } } + + private sealed class BackupMetadataValidationFile + { + public int Version { get; init; } + public required string Namespace { get; init; } + public required string CodexHome { get; init; } + public string? SqliteHome { get; init; } + public required string TargetProvider { get; init; } + public required DateTimeOffset CreatedAt { get; init; } + public required List DbFiles { get; init; } + public List SqliteDbFiles { get; init; } = []; + public int ChangedSessionFiles { get; init; } + public Dictionary? GlobalStateFiles { get; init; } + public bool? GlobalStateFilePresent { get; init; } + public bool? GlobalStateBackupFilePresent { get; init; } + } } internal sealed record BackupRecoveryCoverage(bool Config, bool Database, bool Sessions); diff --git a/desktop/CodexProviderSync.Core/CodexProviderSync.Core.csproj b/desktop/CodexProviderSync.Core/CodexProviderSync.Core.csproj index de5ac19..2c51005 100644 --- a/desktop/CodexProviderSync.Core/CodexProviderSync.Core.csproj +++ b/desktop/CodexProviderSync.Core/CodexProviderSync.Core.csproj @@ -4,9 +4,9 @@ net10.0 enable enable - 0.4.0 - 0.4.0.0 - 0.4.0.0 + 0.5.0 + 0.5.0.0 + 0.5.0.0 diff --git a/desktop/CodexProviderSync.Core/CodexSyncService.cs b/desktop/CodexProviderSync.Core/CodexSyncService.cs index 8778207..6a7bc2b 100644 --- a/desktop/CodexProviderSync.Core/CodexSyncService.cs +++ b/desktop/CodexProviderSync.Core/CodexSyncService.cs @@ -1,3 +1,5 @@ +using System.Diagnostics; + namespace CodexProviderSync.Core; public sealed class CodexSyncService @@ -50,13 +52,16 @@ public async Task GetStatusAsync( string? explicitCodexHome = null, string? explicitSqliteHome = null) { + long totalStarted = Stopwatch.GetTimestamp(); string codexHome = _codexHomeService.NormalizeCodexHome(explicitCodexHome); await _codexHomeService.EnsureCodexHomeAsync(codexHome); string configText = await _configFileService.ReadConfigTextAsync(_codexHomeService.ConfigPath(codexHome)); CodexStorageLayout storage = await PrepareStorageAsync(codexHome, explicitSqliteHome, configText); CurrentProviderInfo currentProvider = _configFileService.ReadCurrentProviderFromConfigText(configText); IReadOnlyList configuredProviders = _configFileService.ListConfiguredProviderIds(configText); + long rolloutScanStarted = Stopwatch.GetTimestamp(); SessionChangeCollection rolloutInfo = await _sessionRolloutService.CollectSessionChangesAsync(codexHome, "__status_only__", skipLockedReads: true); + long rolloutScanDurationMs = ElapsedMilliseconds(rolloutScanStarted); StateDbLocation? stateDbLocation = storage.StateDbLocation; ProviderCounts? sqliteCounts = storage.SqliteAccess.Supported ? await _sqliteStateService.ReadSqliteProviderCountsAsync(storage) @@ -71,7 +76,9 @@ public async Task GetStatusAsync( || sqliteCounts?.Unreadable == true ? [] : await _globalStateService.ReadProjectThreadVisibilityAsync(storage); + long backupSummaryStarted = Stopwatch.GetTimestamp(); BackupSummary backupSummary = await _backupService.GetBackupSummaryAsync(codexHome); + long backupSummaryDurationMs = ElapsedMilliseconds(backupSummaryStarted); IReadOnlyList pendingTransactions = await FileTransactionJournal.FindPendingAsync(codexHome); return new StatusSnapshot @@ -100,7 +107,14 @@ public async Task GetStatusAsync( item.State, item.BackupDir, item.JournalPath)) - .ToArray() + .ToArray(), + PerformanceMetrics = new StatusPerformanceMetrics + { + TotalDurationMs = ElapsedMilliseconds(totalStarted), + RolloutScanDurationMs = rolloutScanDurationMs, + BackupSummaryDurationMs = backupSummaryDurationMs, + RolloutScan = rolloutInfo.ScanMetrics + } }; } @@ -260,7 +274,8 @@ private async Task PrepareSyncAsync( private async Task BuildSyncPlanSnapshotAsync( SyncPreparation preparation, int keepCount, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + bool useContentFingerprintHints = true) { string operation = preparation.SwitchPreparation is null ? "sync" : "switch"; List targets = @@ -339,6 +354,15 @@ private async Task BuildSyncPlanSnapshotAsync( "delete", CoreWriteFingerprintMode.RecursiveInventory)), warnings: warnings, + contentFingerprintHints: useContentFingerprintHints + ? preparation.WritableChanges + .Where(static change => change.ContentFingerprint is not null) + .Select(static change => new CoreWriteContentFingerprintHint( + change.Path, + change.ContentFingerprint!, + change.OriginalFileLength, + change.OriginalLastWriteTimeUtcTicks)) + : null, cancellationToken: cancellationToken); } @@ -366,12 +390,14 @@ private async Task RunSyncCoreAsync( DateTimeOffset? snapshotExpiresAtUtc, CancellationToken cancellationToken = default) { + long totalStarted = Stopwatch.GetTimestamp(); ValidateAutomaticRetention(keepCount); string codexHome = _codexHomeService.NormalizeCodexHome(explicitCodexHome); await _codexHomeService.EnsureCodexHomeAsync(codexHome); await using LockHandle _ = await _lockService.AcquireLockAsync(codexHome, "sync"); await FileTransactionJournal.AssertNoPendingAsync(codexHome); + long preparationStarted = Stopwatch.GetTimestamp(); SyncPreparation preparation = await PrepareSyncAsync( codexHome, provider, @@ -380,6 +406,7 @@ private async Task RunSyncCoreAsync( explicitSqliteHome, switchPreparationFactory, cancellationToken); + long preparationDurationMs = ElapsedMilliseconds(preparationStarted); string configPath = preparation.ConfigPath; string configText = preparation.ConfigText; SwitchPreparation? switchPreparation = preparation.SwitchPreparation; @@ -394,16 +421,20 @@ private async Task RunSyncCoreAsync( List skippedRolloutFiles = [.. preparation.SkippedRolloutFiles]; IReadOnlyList skippedUnreadableRolloutFiles = preparation.SkippedUnreadableRolloutFiles; IReadOnlyList? checkedAutoPruneDeletionTargets = null; + long checkedPlanValidationDurationMs = 0; if (expectedSnapshot is not null) { + long checkedPlanValidationStarted = Stopwatch.GetTimestamp(); AssertSnapshotFresh(snapshotExpiresAtUtc); CoreWritePlanSnapshot actualSnapshot = await BuildSyncPlanSnapshotAsync( preparation, keepCount, - cancellationToken); + cancellationToken, + useContentFingerprintHints: false); CoreWriteSnapshotBuilder.AssertExactMatch(expectedSnapshot, actualSnapshot); AssertSnapshotFresh(snapshotExpiresAtUtc); checkedAutoPruneDeletionTargets = expectedSnapshot.AutoPruneDeletionTargets; + checkedPlanValidationDurationMs = ElapsedMilliseconds(checkedPlanValidationStarted); } cancellationToken.ThrowIfCancellationRequested(); if (FaultInjector is not null) @@ -411,12 +442,15 @@ private async Task RunSyncCoreAsync( await FaultInjector("before_backup", null, 0); } string? effectiveConfigBackupText = switchPreparation is null ? configBackupText : configText; + long backupStarted = Stopwatch.GetTimestamp(); string backupDir = await _backupService.CreateBackupAsync( storage, targetProvider, writableChanges, configPath, effectiveConfigBackupText); + long backupDurationMs = ElapsedMilliseconds(backupStarted); + long mutationStarted = Stopwatch.GetTimestamp(); List appliedSessionChanges = []; bool sqliteMutationCommitted = false; bool sqliteCommitAttempted = false; @@ -429,7 +463,7 @@ private async Task RunSyncCoreAsync( .Concat(switchPreparation is null ? [] : [Path.GetFullPath(configPath)]) .Concat(storage.StateDbLocation is null ? [] : [Path.GetFullPath(storage.StateDbLocation.Path)]) .ToArray(); - FileTransactionJournal journal = await FileTransactionJournal.CreateAsync( + await using FileTransactionJournal journal = await FileTransactionJournal.CreateOwnedAsync( backupDir, codexHome, targetProvider, @@ -600,6 +634,22 @@ await FaultInjector( } await journal.CommittedAsync(); transactionCommitted = true; + await journal.DisposeAsync(); + // The transaction is already committed and every target is on disk. + // Refreshing the backup inventory only corrects the recorded size + // and file count in metadata.json, so a failure here must degrade + // to a warning: throwing would report a successful sync as failed + // and skip the automatic backup pruning below. + string? backupInventoryWarning = null; + try + { + await _backupService.RefreshMetadataInventoryAsync(backupDir); + } + catch (Exception error) + { + backupInventoryWarning = $"Backup inventory refresh failed: {error.Message}"; + } + long mutationDurationMs = ElapsedMilliseconds(mutationStarted); if (FaultInjector is not null) { await FaultInjector("after_transaction_commit", null, completedTargets.Count); @@ -607,6 +657,7 @@ await FaultInjector( BackupPruneResult? autoPruneResult = null; string? autoPruneWarning = null; + long pruneStarted = Stopwatch.GetTimestamp(); try { autoPruneResult = await _backupService.PruneAutomaticBackupsAsync( @@ -619,6 +670,8 @@ await FaultInjector( { autoPruneWarning = $"Automatic backup cleanup failed: {error.Message}"; } + long pruneDurationMs = ElapsedMilliseconds(pruneStarted); + autoPruneWarning = JoinBackupWarnings(backupInventoryWarning, autoPruneWarning); SyncResult result = new() { @@ -645,7 +698,18 @@ await FaultInjector( AutoPruneResult = autoPruneResult, AutoPruneWarning = autoPruneWarning, ConfigUpdated = switchPreparation is not null, - ModelSync = switchPreparation?.ModelSync ?? ModelSyncOutcome.NotApplicable() + ModelSync = switchPreparation?.ModelSync ?? ModelSyncOutcome.NotApplicable(), + PerformanceMetrics = new SyncPerformanceMetrics + { + TotalDurationMs = ElapsedMilliseconds(totalStarted), + PreparationDurationMs = preparationDurationMs, + CheckedPlanValidationDurationMs = checkedPlanValidationDurationMs, + BackupDurationMs = backupDurationMs, + MutationDurationMs = mutationDurationMs, + PruneDurationMs = pruneDurationMs, + JournalFullValidationCount = journal.AppendFullJournalValidationCount, + RolloutScan = sessionInfo.ScanMetrics + } }; return result; } @@ -712,6 +776,7 @@ await FaultInjector( // Preserve the original and rollback failures when the // journal itself is no longer writable. } + await TryRefreshBackupInventoryAsync(backupDir); IReadOnlyList reportedCompletedTargets = BuildReportedCompletedTargets( completedTargets, observedMutatedTargets); @@ -729,6 +794,7 @@ await FaultInjector( recoveryRequired: true); } + await TryRefreshBackupInventoryAsync(backupDir); IReadOnlyList completedAfterRollback = BuildReportedCompletedTargets( completedTargets, observedMutatedTargets); @@ -1154,7 +1220,27 @@ await FileTransactionJournal.MarkBackupRolledBackAsync( preparation.BackupDirectory, codexHome, result.TargetProvider); - return result; + // The restore and its journal marker are already durable. Refreshing the + // inventory only corrects metadata.json bookkeeping, so surface a + // failure as a warning instead of reporting a completed restore as + // failed. + try + { + await _backupService.RefreshMetadataInventoryAsync(preparation.BackupDirectory); + return result; + } + catch (Exception error) + { + return new RestoreResult + { + CodexHome = result.CodexHome, + BackupDir = result.BackupDir, + TargetProvider = result.TargetProvider, + CreatedAt = result.CreatedAt, + ChangedSessionFiles = result.ChangedSessionFiles, + BackupInventoryWarning = $"Backup inventory refresh failed: {error.Message}" + }; + } } private async Task PrepareRestoreAsync( @@ -1444,6 +1530,38 @@ public Task GetBackupStorageInfoAsync(string backupDir) return $"Encrypted content warning: {total} rollout file(s) contain encrypted_content from provider(s) {string.Join(", ", riskyProviders)}. Visibility metadata can be synchronized to {targetProvider}, but continuing or compacting those histories may fail with invalid_encrypted_content. Return to the original provider/account or start a new session if you need reliable continuation."; } + private static long ElapsedMilliseconds(long started) => + (long)Math.Round(Stopwatch.GetElapsedTime(started).TotalMilliseconds); + + /// + /// Rewrites the retained backup's recorded size and file count after the + /// journal reached a terminal state, so status and pruning do not trust an + /// inventory captured before those journal records existed. Used on the + /// rollback paths, where the caller is already reporting a failure: a + /// bookkeeping problem here must never replace the original error. + /// + private async Task TryRefreshBackupInventoryAsync(string backupDir) + { + try + { + await _backupService.RefreshMetadataInventoryAsync(backupDir); + } + catch + { + // The original sync failure and its rollback details are the + // authoritative diagnosis and must reach the caller unchanged. + } + } + + private static string? JoinBackupWarnings(string? first, string? second) + { + string[] parts = new[] { first, second } + .Where(static part => !string.IsNullOrWhiteSpace(part)) + .Select(static part => part!.Trim()) + .ToArray(); + return parts.Length == 0 ? null : string.Join(" | ", parts); + } + private async Task PrepareStorageAsync( string codexHome, string? explicitSqliteHome, diff --git a/desktop/CodexProviderSync.Core/CoreWritePlanning.cs b/desktop/CodexProviderSync.Core/CoreWritePlanning.cs index 54e51f9..a2ec0bd 100644 --- a/desktop/CodexProviderSync.Core/CoreWritePlanning.cs +++ b/desktop/CodexProviderSync.Core/CoreWritePlanning.cs @@ -54,6 +54,12 @@ internal sealed record CoreWriteTargetSpec( string Action, CoreWriteFingerprintMode FingerprintMode = CoreWriteFingerprintMode.Content); +internal sealed record CoreWriteContentFingerprintHint( + string Path, + string Fingerprint, + long Length, + long LastWriteTimeUtcTicks); + internal static class CoreWriteSnapshotBuilder { private const string FormatVersion = "core-write-snapshot-v2"; @@ -64,17 +70,36 @@ public static async Task BuildAsync( IEnumerable targets, IEnumerable? autoPruneDeletionTargets = null, IEnumerable? warnings = null, - CancellationToken cancellationToken = default) + IEnumerable? contentFingerprintHints = null, + CancellationToken cancellationToken = default, + Action? fingerprintObserver = null) { ArgumentException.ThrowIfNullOrWhiteSpace(operation); ArgumentNullException.ThrowIfNull(binding); + Dictionary<(string Path, CoreWriteFingerprintMode Mode), string> fingerprintCache = []; + foreach (CoreWriteContentFingerprintHint hint in contentFingerprintHints ?? []) + { + string fullPath = Path.GetFullPath(hint.Path); + FileInfo current = new(fullPath); + if (!current.Exists + || current.Length != hint.Length + || current.LastWriteTimeUtc.Ticks != hint.LastWriteTimeUtcTicks) + { + throw new CoreWritePlanStaleException(); + } + fingerprintCache[(fullPath, CoreWriteFingerprintMode.Content)] = hint.Fingerprint; + } IReadOnlyList capturedTargets = await CaptureTargetsAsync( targets, - cancellationToken); + fingerprintCache, + cancellationToken, + fingerprintObserver); IReadOnlyList capturedAutoPruneTargets = await CaptureTargetsAsync( autoPruneDeletionTargets ?? [], - cancellationToken); + fingerprintCache, + cancellationToken, + fingerprintObserver); IReadOnlyList capturedWarnings = (warnings ?? []) .Select(static warning => warning with { }) .OrderBy(static warning => warning.Code, StringComparer.Ordinal) @@ -132,7 +157,9 @@ public static void AssertExactMatch( private static async Task> CaptureTargetsAsync( IEnumerable specs, - CancellationToken cancellationToken) + Dictionary<(string Path, CoreWriteFingerprintMode Mode), string> fingerprintCache, + CancellationToken cancellationToken, + Action? fingerprintObserver) { CoreWriteTargetSpec[] normalized = specs .Select(static spec => new CoreWriteTargetSpec( @@ -144,19 +171,15 @@ private static async Task> CaptureTargetsAsyn .ThenBy(static spec => spec.Action, StringComparer.Ordinal) .ToArray(); List result = new(normalized.Length); - Dictionary<(string Path, CoreWriteFingerprintMode Mode), string> fingerprintCache = []; foreach (CoreWriteTargetSpec spec in normalized) { cancellationToken.ThrowIfCancellationRequested(); - (string Path, CoreWriteFingerprintMode Mode) cacheKey = (spec.Path, spec.FingerprintMode); - if (!fingerprintCache.TryGetValue(cacheKey, out string? fingerprint)) - { - fingerprint = await FingerprintPathAsync( - spec.Path, - spec.FingerprintMode, - cancellationToken); - fingerprintCache.Add(cacheKey, fingerprint); - } + string fingerprint = await FingerprintPathAsync( + spec.Path, + spec.FingerprintMode, + fingerprintCache, + cancellationToken, + fingerprintObserver); result.Add(new CoreWritePlanTarget(spec.Path, spec.Action, fingerprint)); } return result.AsReadOnly(); @@ -165,26 +188,44 @@ private static async Task> CaptureTargetsAsyn private static async Task FingerprintPathAsync( string fullPath, CoreWriteFingerprintMode mode, - CancellationToken cancellationToken) + Dictionary<(string Path, CoreWriteFingerprintMode Mode), string> fingerprintCache, + CancellationToken cancellationToken, + Action? fingerprintObserver) { + fullPath = Path.GetFullPath(fullPath); + (string Path, CoreWriteFingerprintMode Mode) cacheKey = (fullPath, mode); + if (fingerprintCache.TryGetValue(cacheKey, out string? cached)) + { + return cached; + } + cancellationToken.ThrowIfCancellationRequested(); + fingerprintObserver?.Invoke(fullPath); + string fingerprint; if (!File.Exists(fullPath) && !Directory.Exists(fullPath)) { - if (mode == CoreWriteFingerprintMode.SqliteWalContent) - { - return FingerprintEmptySqliteWal(fullPath); - } - return Sha256($"missing\n{fullPath}"); + fingerprint = mode == CoreWriteFingerprintMode.SqliteWalContent + ? FingerprintEmptySqliteWal(fullPath) + : Sha256($"missing\n{fullPath}"); + fingerprintCache.Add(cacheKey, fingerprint); + return fingerprint; } FileAttributes attributes = File.GetAttributes(fullPath); if ((attributes & FileAttributes.ReparsePoint) != 0) { DateTime lastWrite = File.GetLastWriteTimeUtc(fullPath); - return Sha256($"reparse\n{fullPath}\n{(int)attributes}\n{lastWrite.Ticks}"); + fingerprint = Sha256($"reparse\n{fullPath}\n{(int)attributes}\n{lastWrite.Ticks}"); + fingerprintCache.Add(cacheKey, fingerprint); + return fingerprint; } - return (attributes & FileAttributes.Directory) != 0 - ? await FingerprintDirectoryAsync(fullPath, mode, cancellationToken) + fingerprint = (attributes & FileAttributes.Directory) != 0 + ? await FingerprintDirectoryAsync( + fullPath, + mode, + fingerprintCache, + cancellationToken, + fingerprintObserver) : mode switch { CoreWriteFingerprintMode.RecursiveInventory => FingerprintFileInventory(fullPath, attributes), @@ -198,12 +239,16 @@ private static async Task FingerprintPathAsync( cancellationToken), _ => await FingerprintFileAsync(fullPath, cancellationToken) }; + fingerprintCache.Add(cacheKey, fingerprint); + return fingerprint; } private static async Task FingerprintDirectoryAsync( string directoryPath, CoreWriteFingerprintMode mode, - CancellationToken cancellationToken) + Dictionary<(string Path, CoreWriteFingerprintMode Mode), string> fingerprintCache, + CancellationToken cancellationToken, + Action? fingerprintObserver) { StringBuilder canonical = new(); Append(canonical, "type", "directory"); @@ -231,7 +276,12 @@ private static async Task FingerprintDirectoryAsync( Append( canonical, "entry.fingerprint", - await FingerprintPathAsync(entry, mode, cancellationToken)); + await FingerprintPathAsync( + entry, + mode, + fingerprintCache, + cancellationToken, + fingerprintObserver)); } return Sha256(canonical.ToString()); } diff --git a/desktop/CodexProviderSync.Core/Models.cs b/desktop/CodexProviderSync.Core/Models.cs index 4f744c8..e7158b7 100644 --- a/desktop/CodexProviderSync.Core/Models.cs +++ b/desktop/CodexProviderSync.Core/Models.cs @@ -37,6 +37,16 @@ public sealed class StatusSnapshot public required string BackupRoot { get; init; } public required BackupSummary BackupSummary { get; init; } public IReadOnlyList PendingTransactions { get; init; } = []; + [JsonIgnore] + public StatusPerformanceMetrics PerformanceMetrics { get; init; } = new(); +} + +public sealed class StatusPerformanceMetrics +{ + public long TotalDurationMs { get; init; } + public long RolloutScanDurationMs { get; init; } + public long BackupSummaryDurationMs { get; init; } + public SessionScanMetrics RolloutScan { get; init; } = new(); } public sealed record TransactionRecoveryInfo( @@ -119,6 +129,8 @@ public sealed class SessionChange public required string UpdatedFirstLine { get; init; } public bool ModelOnlyChange { get; init; } public IReadOnlyList OriginalTurnContextModels { get; set; } = []; + [JsonIgnore] + public string? ContentFingerprint { get; init; } } public sealed class TurnContextModelBackup @@ -137,6 +149,16 @@ public sealed class SessionChangeCollection public required ProviderCounts EncryptedContentCounts { get; init; } public required IReadOnlyCollection UserEventThreadIds { get; init; } public required IReadOnlyDictionary ThreadCwdsById { get; init; } + public SessionScanMetrics ScanMetrics { get; init; } = new(); +} + +public sealed class SessionScanMetrics +{ + public int EnumeratedRolloutFiles { get; init; } + public int ParsedSessionFiles { get; init; } + public int ContentScanPasses { get; init; } + public int ModelScanFiles { get; init; } + public long DurationMs { get; init; } } public sealed class SyncResult @@ -165,6 +187,20 @@ public sealed class SyncResult public ModelSyncOutcome ModelSync { get; init; } = ModelSyncOutcome.NotApplicable(); public BackupPruneResult? AutoPruneResult { get; init; } public string? AutoPruneWarning { get; init; } + [JsonIgnore] + public SyncPerformanceMetrics PerformanceMetrics { get; init; } = new(); +} + +public sealed class SyncPerformanceMetrics +{ + public long TotalDurationMs { get; init; } + public long PreparationDurationMs { get; init; } + public long CheckedPlanValidationDurationMs { get; init; } + public long BackupDurationMs { get; init; } + public long MutationDurationMs { get; init; } + public long PruneDurationMs { get; init; } + public int JournalFullValidationCount { get; init; } + public SessionScanMetrics RolloutScan { get; init; } = new(); } public sealed class ModelSyncOutcome @@ -209,6 +245,14 @@ public sealed class RestoreResult public required string TargetProvider { get; init; } public DateTimeOffset? CreatedAt { get; init; } public int ChangedSessionFiles { get; init; } + + /// + /// Set when the restore itself succeeded but refreshing the backup + /// directory inventory afterwards did not. The restored state is already + /// authoritative; only the recorded size and file count in metadata.json + /// may be stale. + /// + public string? BackupInventoryWarning { get; init; } } public sealed class BackupStorageInfo @@ -281,6 +325,8 @@ internal sealed class BackupMetadataFile public Dictionary? GlobalStateFiles { get; init; } public bool? GlobalStateFilePresent { get; init; } public bool? GlobalStateBackupFilePresent { get; init; } + public long? SizeBytes { get; init; } + public int? FileCount { get; init; } } internal sealed class SessionBackupManifest diff --git a/desktop/CodexProviderSync.Core/SessionRolloutService.cs b/desktop/CodexProviderSync.Core/SessionRolloutService.cs index bad86b9..2396166 100644 --- a/desktop/CodexProviderSync.Core/SessionRolloutService.cs +++ b/desktop/CodexProviderSync.Core/SessionRolloutService.cs @@ -1,4 +1,6 @@ using System.Buffers; +using System.Diagnostics; +using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; @@ -9,16 +11,26 @@ namespace CodexProviderSync.Core; public sealed class SessionRolloutService { private const string StatusOnlyProvider = "__status_only__"; - private const int ScanBufferSize = 1024 * 1024; - internal Func? ApplyFaultInjector { get; set; } + /// + /// Test seam invoked after a rollout's content digest is folded but before + /// its post-scan snapshot is taken, so a test can deterministically mutate + /// the file inside that window. + /// + internal Func? ScanFaultInjector { get; set; } + public async Task CollectSessionChangesAsync( string codexHome, string targetProvider, bool skipLockedReads = false, string? targetModel = null) { + long scanStarted = Stopwatch.GetTimestamp(); + int enumeratedRolloutFiles = 0; + int parsedSessionFiles = 0; + int contentScanPasses = 0; + int modelScanFiles = 0; List changes = []; List lockedPaths = []; List unreadablePaths = []; @@ -41,9 +53,12 @@ public async Task CollectSessionChangesAsync( .EnumerateFiles(rootDir, "rollout-*.jsonl", SearchOption.AllDirectories) .Order(StringComparer.Ordinal)) { + enumeratedRolloutFiles += 1; FirstLineRecord record; + FileSnapshot scanStart; try { + scanStart = GetFileSnapshot(rolloutPath); record = await ReadFirstLineRecordAsync(rolloutPath); } catch (Exception error) when (skipLockedReads && IsRolloutFileBusyError(error)) @@ -61,6 +76,7 @@ record = await ReadFirstLineRecordAsync(rolloutPath); { continue; } + parsedSessionFiles += 1; string currentProvider = payload!["model_provider"]?.GetValue() ?? "(missing)"; Dictionary bucket = dirName == "archived_sessions" ? archivedCounts : sessionCounts; @@ -72,14 +88,22 @@ record = await ReadFirstLineRecordAsync(rolloutPath); { threadCwdsById[metadataThreadId] = ToDesktopWorkspacePath(metadataCwd); } - bool hasEncryptedContent; + RolloutContentScan contentScan; try { - hasEncryptedContent = await FileHasEncryptedContentAsync(rolloutPath, record.FirstLine, record.Offset); - if (payload["id"]?.GetValue() is string threadId - && await FileHasUserEventAsync(rolloutPath, record.FirstLine, record.Offset)) + bool collectModels = !string.IsNullOrEmpty(targetModel); + bool providerMayChange = !string.Equals(targetProvider, StatusOnlyProvider, StringComparison.Ordinal) + && !string.Equals(currentProvider, targetProvider, StringComparison.Ordinal); + contentScan = await ScanRolloutContentAsync( + rolloutPath, + record, + collectModels, + collectFingerprint: providerMayChange); + contentScanPasses += 1; + modelScanFiles += collectModels ? 1 : 0; + if (ScanFaultInjector is not null) { - userEventThreadIds.Add(threadId); + await ScanFaultInjector(rolloutPath); } } catch (Exception error) when (skipLockedReads && IsRolloutFileBusyError(error)) @@ -93,7 +117,13 @@ record = await ReadFirstLineRecordAsync(rolloutPath); continue; } - if (hasEncryptedContent) + if (payload["id"]?.GetValue() is string threadId + && contentScan.HasUserEvent) + { + userEventThreadIds.Add(threadId); + } + + if (contentScan.HasEncryptedContent) { Dictionary encryptedBucket = dirName == "archived_sessions" ? encryptedArchivedCounts : encryptedSessionCounts; encryptedBucket[currentProvider] = encryptedBucket.TryGetValue(currentProvider, out int encryptedCount) ? encryptedCount + 1 : 1; @@ -106,31 +136,35 @@ record = await ReadFirstLineRecordAsync(rolloutPath); bool modelChanged = false; if (!string.IsNullOrEmpty(targetModel)) { - try - { - currentModelBackups = await ReadTurnContextModelBackupsAsync(rolloutPath, record); - currentModels = currentModelBackups - .SelectMany(static backup => backup.OriginalModels.Count > 0 - ? backup.OriginalModels - : [backup.OriginalModel]) - .ToArray(); - modelChanged = currentModels.Any(model => !string.Equals(model, targetModel, StringComparison.Ordinal)); - } - catch (Exception error) when (skipLockedReads && IsRolloutFileBusyError(error)) - { - lockedPaths.Add(rolloutPath); - continue; - } - catch (Exception error) when (skipLockedReads && IsRolloutFileUnreadableError(error)) - { - unreadablePaths.Add(rolloutPath); - continue; - } + currentModelBackups = contentScan.TurnContextModels; + currentModels = currentModelBackups + .SelectMany(static backup => backup.OriginalModels.Count > 0 + ? backup.OriginalModels + : [backup.OriginalModel]) + .ToArray(); + modelChanged = currentModels.Any(model => !string.Equals(model, targetModel, StringComparison.Ordinal)); } if (providerChanged || modelChanged) { FileSnapshot snapshot = GetFileSnapshot(rolloutPath); + if (contentScan.ContentFingerprint is not null && snapshot != scanStart) + { + // The rollout grew or was touched while we were folding + // its content digest, so the fingerprint describes a + // state that no longer exists and must not be cached as + // a plan hint. An active Codex session appending to its + // own rollout is the ordinary cause, so report it the + // same way a busy file is reported and keep rewriting + // the rest instead of aborting the whole sync. + if (skipLockedReads) + { + lockedPaths.Add(rolloutPath); + continue; + } + + throw new CoreWritePlanStaleException(); + } if (providerChanged) { payload["model_provider"] = targetProvider; @@ -148,7 +182,10 @@ record = await ReadFirstLineRecordAsync(rolloutPath); OriginalProvider = currentProvider, UpdatedFirstLine = providerChanged ? root!.ToJsonString() : record.FirstLine, ModelOnlyChange = !providerChanged && modelChanged, - OriginalTurnContextModels = currentModelBackups + OriginalTurnContextModels = currentModelBackups, + ContentFingerprint = contentScan.ContentFingerprint is null + ? null + : $"sha256:{contentScan.ContentFingerprint}:{snapshot.Length}:{snapshot.LastWriteTimeUtcTicks}" }); } } @@ -170,7 +207,15 @@ record = await ReadFirstLineRecordAsync(rolloutPath); ArchivedSessions = encryptedArchivedCounts }, UserEventThreadIds = userEventThreadIds, - ThreadCwdsById = threadCwdsById + ThreadCwdsById = threadCwdsById, + ScanMetrics = new SessionScanMetrics + { + EnumeratedRolloutFiles = enumeratedRolloutFiles, + ParsedSessionFiles = parsedSessionFiles, + ContentScanPasses = contentScanPasses, + ModelScanFiles = modelScanFiles, + DurationMs = (long)Math.Round(Stopwatch.GetElapsedTime(scanStarted).TotalMilliseconds) + } }; } @@ -463,12 +508,20 @@ private static async Task ReadFirstLineRecordAsync(FileStream s bool crlf = newlineIndex > 0 && current[newlineIndex - 1] == '\r'; int lineLength = crlf ? newlineIndex - 1 : newlineIndex; string firstLine = Encoding.UTF8.GetString(current[..lineLength]); - return new FirstLineRecord(firstLine, crlf ? "\r\n" : "\n", newlineIndex + 1); + return new FirstLineRecord( + firstLine, + crlf ? "\r\n" : "\n", + newlineIndex + 1, + current[..(newlineIndex + 1)].ToArray()); } } string text = Encoding.UTF8.GetString(collected.GetBuffer(), 0, (int)collected.Length); - return new FirstLineRecord(text, string.Empty, (int)collected.Length); + return new FirstLineRecord( + text, + string.Empty, + (int)collected.Length, + collected.GetBuffer().AsSpan(0, (int)collected.Length).ToArray()); } finally { @@ -499,27 +552,72 @@ private static async Task ReadFirstLineRecordAsync(FileStream s "\"model\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"", RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static async Task> ReadTurnContextModelBackupsAsync( + private static async Task ScanRolloutContentAsync( string rolloutPath, - FirstLineRecord record) + FirstLineRecord record, + bool collectModels, + bool collectFingerprint) { List backups = []; + bool hasEncryptedContent = record.FirstLine.Contains("encrypted_content", StringComparison.Ordinal); + bool hasUserEvent = false; + try + { + hasUserEvent = RecordHasUserEvent(JsonNode.Parse(record.FirstLine)); + } + catch + { + // Keep scanning the rest of the rollout below. + } try { await using FileStream stream = new( rolloutPath, FileMode.Open, FileAccess.Read, - FileShare.ReadWrite | FileShare.Delete); + FileShare.Read, + 64 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); stream.Seek(record.Offset, SeekOrigin.Begin); - using StreamReader reader = new(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 4096, leaveOpen: true); + using IncrementalHash? contentHash = collectFingerprint + ? IncrementalHash.CreateHash(HashAlgorithmName.SHA256) + : null; + contentHash?.AppendData(record.PrefixBytes); + using HashingReadStream hashingStream = new(stream, contentHash); + using StreamReader reader = new( + hashingStream, + Encoding.UTF8, + detectEncodingFromByteOrderMarks: false, + bufferSize: 64 * 1024, + leaveOpen: true); int lineIndex = 1; string? line; while ((line = await reader.ReadLineAsync().ConfigureAwait(false)) is not null) { - if (!TurnContextTypeRegex.IsMatch(line)) + if (!hasEncryptedContent + && line.Contains("encrypted_content", StringComparison.Ordinal)) + { + hasEncryptedContent = true; + } + if (!hasUserEvent && !string.IsNullOrWhiteSpace(line)) + { + try + { + hasUserEvent = RecordHasUserEvent(JsonNode.Parse(line)); + } + catch + { + // Ignore malformed non-metadata lines; positive evidence is sufficient. + } + } + + if (!collectModels || !TurnContextTypeRegex.IsMatch(line)) { lineIndex += 1; + if (!collectModels && !collectFingerprint && hasEncryptedContent && hasUserEvent) + { + break; + } continue; } @@ -550,7 +648,14 @@ private static async Task> ReadTurnContext } lineIndex += 1; } - return backups; + string? contentFingerprint = contentHash is null + ? null + : Convert.ToHexString(contentHash.GetHashAndReset()).ToLowerInvariant(); + return new RolloutContentScan( + hasEncryptedContent, + hasUserEvent, + backups, + contentFingerprint); } catch (Exception error) when (IsRolloutFileBusyError(error)) { @@ -927,188 +1032,6 @@ private static FileSnapshot GetFileSnapshot(string filePath) return new FileSnapshot(fileInfo.Length, fileInfo.LastWriteTimeUtc.Ticks); } - private static async Task FileContainsTextAsync(string filePath, string text, int startOffset) - { - byte[] needle = Encoding.UTF8.GetBytes(text); - byte[] buffer = ArrayPool.Shared.Rent(ScanBufferSize); - byte[] tail = []; - - try - { - await using FileStream stream = new( - filePath, - FileMode.Open, - FileAccess.Read, - FileShare.Read, - ScanBufferSize, - FileOptions.Asynchronous | FileOptions.SequentialScan); - - if (startOffset > 0) - { - stream.Seek(startOffset, SeekOrigin.Begin); - } - - while (true) - { - int bytesRead = await stream.ReadAsync(buffer.AsMemory(0, ScanBufferSize)); - if (bytesRead == 0) - { - return false; - } - - byte[] haystack = buffer; - int haystackLength = bytesRead; - if (tail.Length > 0) - { - haystackLength = tail.Length + bytesRead; - haystack = ArrayPool.Shared.Rent(haystackLength); - Buffer.BlockCopy(tail, 0, haystack, 0, tail.Length); - Buffer.BlockCopy(buffer, 0, haystack, tail.Length, bytesRead); - } - - try - { - if (ContainsNeedle(haystack, haystackLength, needle)) - { - return true; - } - - int keepBytes = Math.Min(Math.Max(0, needle.Length - 1), haystackLength); - if (keepBytes == 0) - { - tail = []; - } - else - { - tail = new byte[keepBytes]; - Buffer.BlockCopy(haystack, haystackLength - keepBytes, tail, 0, keepBytes); - } - } - finally - { - if (!ReferenceEquals(haystack, buffer)) - { - ArrayPool.Shared.Return(haystack); - } - } - } - } - catch (Exception error) - { - throw WrapRolloutFileBusyError(error, filePath, "scan"); - } - finally - { - ArrayPool.Shared.Return(buffer); - } - } - - private static bool ContainsNeedle(byte[] haystack, int haystackLength, byte[] needle) - { - if (needle.Length == 0) - { - return true; - } - - if (haystackLength < needle.Length) - { - return false; - } - - int lastStart = haystackLength - needle.Length; - for (int index = 0; index <= lastStart; index += 1) - { - bool match = true; - for (int needleIndex = 0; needleIndex < needle.Length; needleIndex += 1) - { - if (haystack[index + needleIndex] != needle[needleIndex]) - { - match = false; - break; - } - } - - if (match) - { - return true; - } - } - - return false; - } - - private static async Task FileHasEncryptedContentAsync(string filePath, string firstLine, int startOffset) - { - if (firstLine.Contains("encrypted_content", StringComparison.Ordinal)) - { - return true; - } - - return await FileContainsTextAsync(filePath, "encrypted_content", startOffset); - } - - private static async Task FileHasUserEventAsync(string filePath, string firstLine, int startOffset) - { - try - { - if (RecordHasUserEvent(JsonNode.Parse(firstLine))) - { - return true; - } - } - catch - { - // Keep scanning the rest of the rollout below. - } - - try - { - await using FileStream stream = new( - filePath, - FileMode.Open, - FileAccess.Read, - FileShare.Read, - 64 * 1024, - FileOptions.Asynchronous | FileOptions.SequentialScan); - if (startOffset > 0) - { - stream.Seek(startOffset, SeekOrigin.Begin); - } - - using StreamReader reader = new( - stream, - Encoding.UTF8, - detectEncodingFromByteOrderMarks: true, - bufferSize: 64 * 1024, - leaveOpen: false); - while (await reader.ReadLineAsync() is string rawLine) - { - if (string.IsNullOrWhiteSpace(rawLine)) - { - continue; - } - - try - { - if (RecordHasUserEvent(JsonNode.Parse(rawLine))) - { - return true; - } - } - catch - { - // Ignore malformed non-metadata lines; provider sync only needs positive evidence. - } - } - - return false; - } - catch (Exception error) - { - throw WrapRolloutFileBusyError(error, filePath, "scan"); - } - } - private static bool RecordHasUserEvent(JsonNode? record) { if (record is not JsonObject root) @@ -1250,8 +1173,77 @@ private static Exception WrapRolloutFileBusyError(Exception error, string filePa error); } - private readonly record struct FirstLineRecord(string FirstLine, string Separator, int Offset); + private readonly record struct FirstLineRecord( + string FirstLine, + string Separator, + int Offset, + byte[] PrefixBytes); private readonly record struct FileSnapshot(long Length, long LastWriteTimeUtcTicks); + private readonly record struct RolloutContentScan( + bool HasEncryptedContent, + bool HasUserEvent, + IReadOnlyList TurnContextModels, + string? ContentFingerprint); + + private sealed class HashingReadStream(Stream inner, IncrementalHash? hash) : Stream + { + public override bool CanRead => inner.CanRead; + public override bool CanSeek => inner.CanSeek; + public override bool CanWrite => false; + public override long Length => inner.Length; + public override long Position { get => inner.Position; set => inner.Position = value; } + public override void Flush() => inner.Flush(); + public override int Read(byte[] buffer, int offset, int count) + { + int read = inner.Read(buffer, offset, count); + if (read > 0) + { + hash?.AppendData(buffer, offset, read); + } + return read; + } + public override int Read(Span buffer) + { + int read = inner.Read(buffer); + if (read > 0) + { + hash?.AppendData(buffer[..read]); + } + return read; + } + public override async Task ReadAsync( + byte[] buffer, + int offset, + int count, + CancellationToken cancellationToken) + { + int read = await inner.ReadAsync(buffer.AsMemory(offset, count), cancellationToken); + if (read > 0) + { + hash?.AppendData(buffer, offset, read); + } + return read; + } + public override async ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default) + { + int read = await inner.ReadAsync(buffer, cancellationToken); + if (read > 0) + { + hash?.AppendData(buffer.Span[..read]); + } + return read; + } + public override long Seek(long offset, SeekOrigin origin) => inner.Seek(offset, origin); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + protected override void Dispose(bool disposing) + { + // The caller owns the underlying rollout stream. + base.Dispose(disposing); + } + } private readonly record struct ModelLineRewrite( string Line, bool Replaced, diff --git a/desktop/CodexProviderSync.Core/TextFormatter.cs b/desktop/CodexProviderSync.Core/TextFormatter.cs index 291d9d9..b58da7c 100644 --- a/desktop/CodexProviderSync.Core/TextFormatter.cs +++ b/desktop/CodexProviderSync.Core/TextFormatter.cs @@ -27,6 +27,34 @@ public static string FormatSyncResult(SyncResult result, string label, string la : FormatSyncResultEnglish(result, label); } + public static string FormatPerformanceMetrics(SyncPerformanceMetrics metrics, string language) + { + bool chinese = IsChinese(language); + return chinese + ? $"性能: 总计={metrics.TotalDurationMs}ms; 准备={metrics.PreparationDurationMs}ms; " + + $"计划校验={metrics.CheckedPlanValidationDurationMs}ms; 备份={metrics.BackupDurationMs}ms; " + + $"写入={metrics.MutationDurationMs}ms; 清理={metrics.PruneDurationMs}ms; " + + $"rollout={metrics.RolloutScan.EnumeratedRolloutFiles}; 内容扫描={metrics.RolloutScan.ContentScanPasses}; " + + $"journal全量校验={metrics.JournalFullValidationCount}" + : $"Performance: total={metrics.TotalDurationMs}ms; prepare={metrics.PreparationDurationMs}ms; " + + $"plan-check={metrics.CheckedPlanValidationDurationMs}ms; backup={metrics.BackupDurationMs}ms; " + + $"mutation={metrics.MutationDurationMs}ms; prune={metrics.PruneDurationMs}ms; " + + $"rollouts={metrics.RolloutScan.EnumeratedRolloutFiles}; content-scans={metrics.RolloutScan.ContentScanPasses}; " + + $"journal-full-validations={metrics.JournalFullValidationCount}"; + } + + public static string FormatPerformanceMetrics(StatusPerformanceMetrics metrics, string language) + { + bool chinese = IsChinese(language); + return chinese + ? $"刷新性能: 总计={metrics.TotalDurationMs}ms; rollout扫描={metrics.RolloutScanDurationMs}ms; " + + $"备份统计={metrics.BackupSummaryDurationMs}ms; rollout={metrics.RolloutScan.EnumeratedRolloutFiles}; " + + $"内容扫描={metrics.RolloutScan.ContentScanPasses}" + : $"Refresh performance: total={metrics.TotalDurationMs}ms; rollout-scan={metrics.RolloutScanDurationMs}ms; " + + $"backup-summary={metrics.BackupSummaryDurationMs}ms; rollouts={metrics.RolloutScan.EnumeratedRolloutFiles}; " + + $"content-scans={metrics.RolloutScan.ContentScanPasses}"; + } + public static string FormatRestoreResult(RestoreResult result) => FormatRestoreResult(result, English); @@ -55,6 +83,13 @@ public static string FormatRestoreResult(RestoreResult result, string language) : $"Backup created at: {result.CreatedAt:O}"); } + if (!string.IsNullOrWhiteSpace(result.BackupInventoryWarning)) + { + lines.Add(IsChinese(language) + ? $"备份清单警告: {result.BackupInventoryWarning}" + : $"Backup inventory warning: {result.BackupInventoryWarning}"); + } + return string.Join(Environment.NewLine, lines); } diff --git a/desktop/CodexProviderSync.Core/TransactionJournalService.cs b/desktop/CodexProviderSync.Core/TransactionJournalService.cs index 6f2a94c..2c4e608 100644 --- a/desktop/CodexProviderSync.Core/TransactionJournalService.cs +++ b/desktop/CodexProviderSync.Core/TransactionJournalService.cs @@ -3,7 +3,7 @@ namespace CodexProviderSync.Core; -internal sealed class FileTransactionJournal +internal sealed class FileTransactionJournal : IAsyncDisposable { internal const string FileName = "transaction-journal.jsonl"; private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); @@ -11,20 +11,46 @@ internal sealed class FileTransactionJournal private readonly string _filePath; private readonly string _operationId; private readonly SemaphoreSlim _appendGate = new(1, 1); + private FileStream? _writerLease; private int _sequence; + private PendingTransactionInfo? _current; + private long _expectedLength; + private byte[] _expectedTailRecord = []; + private bool _disposed; - private FileTransactionJournal(string filePath, string operationId, int sequence = 0) + private FileTransactionJournal( + string filePath, + string operationId, + PendingTransactionInfo? current = null, + FileStream? writerLease = null) { _filePath = filePath; _operationId = operationId; - _sequence = sequence; + _current = current; + _writerLease = writerLease; + _sequence = current?.LastSequence ?? 0; + _expectedLength = current is null ? 0 : -1; } internal string FilePath => _filePath; + internal int AppendFullJournalValidationCount { get; private set; } + internal Func? AppendFaultInjector { get; set; } - internal Task ReadCurrentInfoAsync() => ReadInfoAsync(_filePath); + internal async Task ReadCurrentInfoAsync() + { + await _appendGate.WaitAsync(); + try + { + ObjectDisposedException.ThrowIf(_disposed, this); + return (await ReadJournalForCurrentInstanceAsync()).Info; + } + finally + { + _appendGate.Release(); + } + } internal static async Task CreateAsync( string backupDir, @@ -32,23 +58,62 @@ internal static async Task CreateAsync( string targetProvider, IEnumerable potentialTargets) { + return await CreateCoreAsync( + backupDir, + codexHome, + targetProvider, + potentialTargets, + acquireWriterLease: false); + } + + internal static async Task CreateOwnedAsync( + string backupDir, + string codexHome, + string targetProvider, + IEnumerable potentialTargets) + { + return await CreateCoreAsync( + backupDir, + codexHome, + targetProvider, + potentialTargets, + acquireWriterLease: OperatingSystem.IsWindows()); + } + + private static async Task CreateCoreAsync( + string backupDir, + string codexHome, + string targetProvider, + IEnumerable potentialTargets, + bool acquireWriterLease) + { + string filePath = Path.Combine(backupDir, FileName); string operationId = Guid.NewGuid().ToString("D"); - FileTransactionJournal journal = new( - Path.Combine(backupDir, FileName), - operationId); - await journal.AppendAsync("prepared", new Dictionary - { - ["protocolVersion"] = 1, - ["backupDir"] = Path.GetFullPath(backupDir), - ["codexHome"] = Path.GetFullPath(codexHome), - ["targetProvider"] = targetProvider, - ["potentialTargets"] = potentialTargets - .Select(Path.GetFullPath) - .Distinct(PathComparer) - .Order(PathComparer) - .ToArray() - }); - return journal; + FileStream? writerLease = acquireWriterLease + ? OpenWriterLease(filePath, FileMode.CreateNew) + : null; + FileTransactionJournal journal = new(filePath, operationId, writerLease: writerLease); + try + { + await journal.AppendAsync("prepared", new Dictionary + { + ["protocolVersion"] = 1, + ["backupDir"] = Path.GetFullPath(backupDir), + ["codexHome"] = Path.GetFullPath(codexHome), + ["targetProvider"] = targetProvider, + ["potentialTargets"] = potentialTargets + .Select(Path.GetFullPath) + .Distinct(PathComparer) + .Order(PathComparer) + .ToArray() + }); + return journal; + } + catch + { + await journal.DisposeAsync(); + throw; + } } internal Task ApplyingAsync(string kind, string targetPath) => AppendAsync( @@ -96,29 +161,9 @@ private async Task AppendAsync(string state, IReadOnlyDictionary 0) - { - before = await ReadInfoAsync(_filePath); - if (before.InvalidTail) - { - throw new InvalidOperationException( - state == "committed" - ? $"Transaction journal is invalid and cannot commit until recovery: {_filePath}" - : $"Transaction journal is invalid and requires recovery before append: {_filePath}"); - } - if (!string.Equals(before.OperationId, _operationId, StringComparison.Ordinal)) - { - throw new InvalidOperationException( - $"Transaction journal operationId changed before append: {_filePath}"); - } - _sequence = before.LastSequence; - } - else if (_sequence != 0) - { - throw new InvalidOperationException( - $"Transaction journal disappeared after it was created: {_filePath}"); - } + ObjectDisposedException.ThrowIf(_disposed, this); + bool terminal = state is "committed" or "rolledBack"; + PendingTransactionInfo? before = await EnsureJournalFrontierAsync(forceFullValidation: terminal); ValidateAppendTransition(before, state, details); int nextSequence = _sequence + 1; @@ -141,20 +186,38 @@ private async Task AppendAsync(string state, IReadOnlyDictionary EnsureJournalFrontierAsync(bool forceFullValidation) + { + if (!File.Exists(_filePath)) + { + if (_sequence != 0 || _current is not null) + { + throw new InvalidOperationException( + $"Transaction journal disappeared after it was created: {_filePath}"); + } + _expectedLength = 0; + _expectedTailRecord = []; + return null; + } + + long actualLength = new FileInfo(_filePath).Length; + if (actualLength == 0 && _sequence == 0 && _current is null) + { + _expectedLength = 0; + _expectedTailRecord = []; + return null; + } + bool frontierMatches = _writerLease is not null + && OperatingSystem.IsWindows() + && !forceFullValidation + && actualLength == _expectedLength + && await TailMatchesAsync(actualLength, _expectedTailRecord); + if (!frontierMatches) + { + JournalReadResult read = await ReadJournalForAppendAsync(); + AdoptJournalState(read); + } + + if (_current is null) + { + return null; + } + if (_current.InvalidTail) + { + throw new InvalidOperationException( + forceFullValidation + ? $"Transaction journal is invalid and cannot commit until recovery: {_filePath}" + : $"Transaction journal is invalid and requires recovery before append: {_filePath}"); + } + if (!string.Equals(_current.OperationId, _operationId, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Transaction journal operationId changed before append: {_filePath}"); + } + _sequence = _current.LastSequence; + return _current; + } + + private async Task ReadJournalForAppendAsync() + { + AppendFullJournalValidationCount += 1; + return await ReadJournalForCurrentInstanceAsync(); + } + + private Task ReadJournalForCurrentInstanceAsync() + { + return _writerLease is null + ? ReadJournalAsync(_filePath) + : ReadJournalAsync(_filePath, _writerLease); + } + + private void AdoptJournalState(JournalReadResult read) + { + _current = read.Info; + _sequence = read.Info.LastSequence; + _expectedLength = _writerLease?.Length ?? new FileInfo(_filePath).Length; + _expectedTailRecord = read.ValidLines.Count == 0 + ? [] + : Encoding.UTF8.GetBytes(read.ValidLines[^1] + "\n"); + } + + private async Task VerifyAppendedRecordAsync(long appendOffset, byte[] expectedRecord) + { + if (_writerLease is not null) + { + long leasedExpectedLength = appendOffset + expectedRecord.Length; + if (_writerLease.Length != leasedExpectedLength) + { + throw new InvalidOperationException( + $"Transaction journal changed while appending: {_filePath}"); + } + _writerLease.Seek(appendOffset, SeekOrigin.Begin); + byte[] leasedActual = new byte[expectedRecord.Length]; + await _writerLease.ReadExactlyAsync(leasedActual); + if (!leasedActual.AsSpan().SequenceEqual(expectedRecord)) + { + throw new InvalidOperationException( + $"Transaction journal append bytes could not be verified: {_filePath}"); + } + return; + } + + await using FileStream stream = new( + _filePath, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete, + 4096, + FileOptions.Asynchronous | FileOptions.SequentialScan); + long expectedLength = appendOffset + expectedRecord.Length; + if (stream.Length != expectedLength) + { + throw new InvalidOperationException( + $"Transaction journal changed while appending: {_filePath}"); + } + + stream.Seek(appendOffset, SeekOrigin.Begin); + byte[] actual = new byte[expectedRecord.Length]; + await stream.ReadExactlyAsync(actual); + if (!actual.AsSpan().SequenceEqual(expectedRecord)) + { + throw new InvalidOperationException( + $"Transaction journal append bytes could not be verified: {_filePath}"); + } + } + + private async Task TailMatchesAsync(long actualLength, byte[] expectedTail) + { + if (actualLength == 0) + { + return expectedTail.Length == 0; + } + if (expectedTail.Length == 0 || actualLength < expectedTail.Length) + { + return false; + } + + if (_writerLease is not null) + { + if (_writerLease.Length != actualLength) + { + return false; + } + _writerLease.Seek(actualLength - expectedTail.Length, SeekOrigin.Begin); + byte[] leasedTail = new byte[expectedTail.Length]; + await _writerLease.ReadExactlyAsync(leasedTail); + return leasedTail.AsSpan().SequenceEqual(expectedTail); + } + + await using FileStream stream = new( + _filePath, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete, + 4096, + FileOptions.Asynchronous | FileOptions.SequentialScan); + if (stream.Length != actualLength) + { + return false; + } + stream.Seek(actualLength - expectedTail.Length, SeekOrigin.Begin); + byte[] actualTail = new byte[expectedTail.Length]; + await stream.ReadExactlyAsync(actualTail); + return actualTail.AsSpan().SequenceEqual(expectedTail); + } + + public async ValueTask DisposeAsync() + { + await _appendGate.WaitAsync(); + try + { + if (_disposed) + { + return; + } + _disposed = true; + FileStream? writerLease = Interlocked.Exchange(ref _writerLease, null); + if (writerLease is not null) + { + await writerLease.DisposeAsync(); + } + } + finally + { + _appendGate.Release(); + } + } + + private static FileStream OpenWriterLease(string filePath, FileMode mode) + { + return new FileStream( + filePath, + mode, + FileAccess.ReadWrite, + FileShare.Read, + 4096, + FileOptions.Asynchronous | FileOptions.WriteThrough | FileOptions.SequentialScan); + } + + private PendingTransactionInfo AdvanceJournalState( + PendingTransactionInfo? current, + string nextState, + IReadOnlyDictionary? details, + int nextSequence) + { + IReadOnlyList potentialTargets = current?.PotentialTargets + ?? ReadPreparedPotentialTargets(details); + Dictionary affected = new(PathComparer); + if (current is not null) + { + foreach (TransactionTargetInfo target in current.AffectedTargets) + { + affected[target.Kind + "\0" + Path.GetFullPath(target.TargetPath)] = target; + } + } + + if (nextState is "applying" or "applied" or "skipped") + { + string kind = ReadRequiredDetail(details, "kind"); + string targetPath = Path.GetFullPath(ReadRequiredDetail(details, "targetPath")); + string key = kind + "\0" + targetPath; + if (nextState == "skipped") + { + affected.Remove(key); + } + else + { + affected[key] = new TransactionTargetInfo(kind, targetPath, nextState); + } + } + + bool terminal = nextState is "committed" or "rolledBack"; + string journalPath = current?.JournalPath ?? _filePath; + return new PendingTransactionInfo( + journalPath, + current?.BackupDir ?? Path.GetDirectoryName(_filePath)!, + current?.OperationId ?? _operationId, + nextSequence, + nextState, + terminal, + InvalidTail: false, + LastValidState: nextState, + potentialTargets, + affected.Values.ToArray()); + } + + private static IReadOnlyList ReadPreparedPotentialTargets( + IReadOnlyDictionary? details) + { + if (details is not null + && details.TryGetValue("potentialTargets", out object? value) + && value is IEnumerable targets) + { + return targets + .Select(Path.GetFullPath) + .Distinct(PathComparer) + .Order(PathComparer) + .ToArray(); + } + return []; + } + private static void ValidateAppendTransition( PendingTransactionInfo? current, string nextState, @@ -436,7 +786,7 @@ await replacement.RollingBackAsync( FileTransactionJournal journal = new( journalPath, info.OperationId!, - info.LastSequence); + info); if (info.State is not ("rollingBack" or "recoveryRequired")) { await journal.RollingBackAsync(new InvalidOperationException("Explicit managed-backup restore")); @@ -465,6 +815,23 @@ internal static async Task ReadInfoAsync(string journalP private static async Task ReadJournalAsync(string journalPath) { byte[] journalBytes = await File.ReadAllBytesAsync(journalPath); + return ParseJournal(journalPath, journalBytes); + } + + private static async Task ReadJournalAsync(string journalPath, FileStream stream) + { + if (stream.Length > int.MaxValue) + { + throw new InvalidOperationException($"Transaction journal is too large to validate: {journalPath}"); + } + byte[] journalBytes = new byte[(int)stream.Length]; + stream.Seek(0, SeekOrigin.Begin); + await stream.ReadExactlyAsync(journalBytes); + return ParseJournal(journalPath, journalBytes); + } + + private static JournalReadResult ParseJournal(string journalPath, byte[] journalBytes) + { bool missingTerminalLf = journalBytes.Length > 0 && journalBytes[^1] != (byte)'\n'; string journalText = Encoding.UTF8.GetString(journalBytes); string? operationId = null; diff --git a/desktop/CodexProviderSync.GuiE2E.Tests/ApplicationAndDialogContractTests.cs b/desktop/CodexProviderSync.GuiE2E.Tests/ApplicationAndDialogContractTests.cs index c114784..eba44b7 100644 --- a/desktop/CodexProviderSync.GuiE2E.Tests/ApplicationAndDialogContractTests.cs +++ b/desktop/CodexProviderSync.GuiE2E.Tests/ApplicationAndDialogContractTests.cs @@ -100,7 +100,7 @@ public void Verify_AcceptsExpectedConfirmationAndNoUpdateDialogs() "update-no-update", "#32770", "Codex Provider Sync", - ["当前已是最新版本(v0.4.0)。", "确定"]); + ["当前已是最新版本(v0.5.0)。", "确定"]); Assert.True(sync.Passed, sync.Error); Assert.True(noUpdate.Passed, noUpdate.Error); diff --git a/desktop/CodexProviderSync.GuiE2E/CodexProviderSync.GuiE2E.csproj b/desktop/CodexProviderSync.GuiE2E/CodexProviderSync.GuiE2E.csproj index 8947d2a..c008ca0 100644 --- a/desktop/CodexProviderSync.GuiE2E/CodexProviderSync.GuiE2E.csproj +++ b/desktop/CodexProviderSync.GuiE2E/CodexProviderSync.GuiE2E.csproj @@ -7,9 +7,9 @@ enable true CodexProviderSync.GuiE2E - 0.4.0 - 0.4.0.0 - 0.4.0.0 + 0.5.0 + 0.5.0.0 + 0.5.0.0 diff --git a/desktop/CodexProviderSync.Mac/CodexProviderSync.Mac.csproj b/desktop/CodexProviderSync.Mac/CodexProviderSync.Mac.csproj index 55b5856..ec3b6aa 100644 --- a/desktop/CodexProviderSync.Mac/CodexProviderSync.Mac.csproj +++ b/desktop/CodexProviderSync.Mac/CodexProviderSync.Mac.csproj @@ -17,9 +17,9 @@ CodexProviderSync Codex Provider Sync Dailin521 - 0.4.0 - 0.4.0.0 - 0.4.0.0 + 0.5.0 + 0.5.0.0 + 0.5.0.0 true diff --git a/docs/ADR-0001-v0.4-automation-architecture.md b/docs/ADR-0001-v0.4-automation-architecture.md index 8a9a957..5096d8e 100644 --- a/docs/ADR-0001-v0.4-automation-architecture.md +++ b/docs/ADR-0001-v0.4-automation-architecture.md @@ -4,6 +4,8 @@ - Date: 2026-08-03 - Scope: v0.4.0 +> Historical ADR: this records the v0.4 decision and is not a current user guide or a stable protocol guarantee. + ## Context PR #70 establishes a UI-independent controller seam but leaves restore, prune, diff --git a/docs/AUTOMATION_DESIGN_NOTES.md b/docs/AUTOMATION_DESIGN_NOTES.md index ecde9ce..2d44da8 100644 --- a/docs/AUTOMATION_DESIGN_NOTES.md +++ b/docs/AUTOMATION_DESIGN_NOTES.md @@ -9,6 +9,8 @@ > published on 2026-08-04. Protocol `0.4` remains a pre-1.0 compatibility > boundary. +For basic command usage, start with the [Automation quickstart](AUTOMATION_QUICKSTART_ZH.md). This document is the detailed historical and technical reference. + ## Motivation Business scripting and GUI regression tests must exercise the same behavior as diff --git a/docs/AUTOMATION_QUICKSTART_ZH.md b/docs/AUTOMATION_QUICKSTART_ZH.md index 15fa306..19b9819 100644 --- a/docs/AUTOMATION_QUICKSTART_ZH.md +++ b/docs/AUTOMATION_QUICKSTART_ZH.md @@ -67,4 +67,4 @@ $planDigest = $planResponse.data.digest | `restore` | 恢复托管备份 | | `prune` | 清理旧的托管备份 | -完整参数、安全限制和返回结构见项目 [README](../README.md) 与 `automation-protocol-v0.4.schema.json`。协议 `0.4` 仍处于 1.0 之前的实验阶段,未来可能发生不兼容变更。 +完整参数和返回结构见 `automation-protocol-v0.4.schema.json`;架构、安全限制和兼容边界见 [Automation Design Notes](AUTOMATION_DESIGN_NOTES.md)。协议 `0.4` 仍处于 1.0 之前的实验阶段,未来可能发生不兼容变更。 diff --git a/docs/MAIN_BRANCH_PROTECTION.md b/docs/MAIN_BRANCH_PROTECTION.md index ab8cef5..b3fa649 100644 --- a/docs/MAIN_BRANCH_PROTECTION.md +++ b/docs/MAIN_BRANCH_PROTECTION.md @@ -1,7 +1,7 @@ # `main` 分支保护方案 -> 状态:`ci-gate` 已通过 PR #63 合并到 `main`;`main-protection` -> Ruleset 已启用,等待测试 PR 验证。 +> 状态:`main-protection` Ruleset 已启用并由后续 PR 验证;`main` 要求通过 +> PR、解决所有审查对话并通过来源为 GitHub Actions 的 `ci-gate`。 > > 适用仓库:`Dailin521/codex-provider-sync` @@ -23,7 +23,7 @@ ## 2. 当前状态 -截至 2026-08-03: +当前治理基线: - `main` 没有重复配置旧式 Branch Protection Rule - [`main-protection`](https://github.com/Dailin521/codex-provider-sync/rules/20265235) @@ -35,15 +35,16 @@ - Restrict deletions 和 Block force pushes 已开启,Strict / Up-to-date 已关闭 - [PR #63](https://github.com/Dailin521/codex-provider-sync/pull/63) 已把稳定的 `ci-gate` 合并到 `main` -- PR #63 的七项检查全部成功;合并后的 `main` push 工作流也成功运行 -- GitHub Actions 工作流 `ci` 当前包含六个实际检查: - - `test (windows-latest, 16)` +- PR #63 已验证 `ci-gate`;合并后的 `main` push 工作流也成功运行 +- GitHub Actions 工作流 `ci` 当前覆盖: + - `test (windows-latest, 16.20.2)` - `test (windows-latest, 24)` - - `test (ubuntu-latest, 16)` + - `test (ubuntu-latest, 16.20.2)` - `test (ubuntu-latest, 24)` - `desktop-test` - `desktop-macos` -- 六项实际检查由第七个稳定检查 `ci-gate` 汇总 + - `desktop-linux-lock` +- 这些检查由稳定的 `ci-gate` 汇总;具体矩阵和 Job 以 [ci.yml](../.github/workflows/ci.yml) 为准 - 工作流顶层权限已限制为 `contents: read` - Secret scanning 和 Push protection 已开启 - 仓库仍以单一主要维护者为主 @@ -100,14 +101,14 @@ Strict 模式要求 PR 分支在合并前包含最新 `main`。当 `main` 有新 当前阶段暂时关闭,原因是: - 单人维护时间有限 -- 六项 CI 有一定运行成本 +- 完整 CI 矩阵有一定运行成本 - 外部贡献者不应因为无关 PR 先合并而频繁重跑 当并发 PR 明显增加、CI 时间稳定且 Merge Queue 已准备好时,再开启 Strict。 ## 4. 使用稳定的 `ci-gate` -不要长期把六个具体检查名称全部写入分支保护规则。 +不要长期把具体检查名称全部写入分支保护规则。 矩阵操作系统、Node 版本或 Job 名称以后可能变化。如果 Ruleset 仍要求一个已经 删除或改名的检查,GitHub 会一直等待它,导致所有 PR 无法正常合并。 @@ -122,6 +123,7 @@ PR #63 已在 `.github/workflows/ci.yml` 增加名称固定的汇总 Job: - test - desktop-test - desktop-macos + - desktop-linux-lock runs-on: ubuntu-latest steps: - name: Verify required jobs @@ -129,10 +131,12 @@ PR #63 已在 `.github/workflows/ci.yml` 增加名称固定的汇总 Job: NODE_TEST_RESULT: ${{ needs.test.result }} DESKTOP_TEST_RESULT: ${{ needs.desktop-test.result }} MACOS_TEST_RESULT: ${{ needs.desktop-macos.result }} + LINUX_LOCK_RESULT: ${{ needs.desktop-linux-lock.result }} run: | if [ "$NODE_TEST_RESULT" != "success" ] || [ "$DESKTOP_TEST_RESULT" != "success" ] || - [ "$MACOS_TEST_RESULT" != "success" ]; then + [ "$MACOS_TEST_RESULT" != "success" ] || + [ "$LINUX_LOCK_RESULT" != "success" ]; then echo "One or more required CI jobs did not succeed." exit 1 fi @@ -143,6 +147,7 @@ PR #63 已在 `.github/workflows/ci.yml` 增加名称固定的汇总 Job: - `test` 的四个矩阵实例全部成功后,其汇总结果才是成功 - Windows Desktop 测试必须成功 - macOS 构建和 Core 测试必须成功 +- Linux 文件锁测试必须成功 - 任一依赖失败、取消或跳过,`ci-gate` 都失败 - Ruleset 只绑定名称稳定的 `ci-gate` @@ -187,7 +192,7 @@ Bypass mode 选择 `For pull requests only`,不要选择 `Always allow`。 必须按以下顺序操作,避免提前把 `main` 锁住: 1. [已完成] 在独立 PR 中向 CI 添加 `ci-gate`。 -2. [已完成] 确认该 PR 的六项现有检查和 `ci-gate` 全部通过。 +2. [已完成] 确认该 PR 的所有依赖检查和 `ci-gate` 全部通过。 3. [已完成] 合并 CI PR。 4. [已完成] 确认 `main` push 工作流中也出现成功的 `ci-gate`。 5. [已完成] 打开仓库 `Settings → Rules → Rulesets`。 @@ -198,7 +203,7 @@ Bypass mode 选择 `For pull requests only`,不要选择 `Always allow`。 status check。 9. [已完成] 启用 PR、对话解决、禁止删除和禁止强推。 10. [已完成] 把 Enforcement status 设为 `Active` 并创建 Ruleset。 -11. [待完成] 使用一个无风险测试 PR 验证规则。 +11. [已完成] 使用后续正常 PR 验证规则。 不要在 `ci-gate` 进入 `main` 之前就把它设为 Required check,否则 GitHub 没有 可运行该检查的默认分支工作流,可能导致 PR 卡住。 diff --git a/docs/NPM_PUBLISHING.md b/docs/NPM_PUBLISHING.md new file mode 100644 index 0000000..d368508 --- /dev/null +++ b/docs/NPM_PUBLISHING.md @@ -0,0 +1,52 @@ +# npm 发布维护指南 + +官方 CLI/Web 包名为 `@dailin521/codex-provider-sync`: + +```bash +npm install -g @dailin521/codex-provider-sync +``` + +npm 包只包含 Node CLI、Web UI 和相关文档。它与 Windows GUI 的 GitHub Release 独立发布;仅更新 CLI/Web 时,不创建 Git tag 或 Windows Release。 + +## 常规发布 + +1. 更新 `package.json` 和 `package-lock.json` 中的版本号;npm 不允许覆盖已发布版本。 +2. 在目标提交上完成本地验证: + + ```bash + npm ci + npm run web:build + npm test + npm run publish:npm -- --dry-run + ``` + +3. 合并到 `main` 并确认 `ci-gate` 成功。 +4. 在 GitHub Actions 手动运行 `publish npm`。工作流仅允许从 `main` 运行,使用 npm Trusted Publisher 的短期 OIDC 凭据,不需要长期 npm token。 +5. 发布后核对: + + ```bash + npm view @dailin521/codex-provider-sync version + npm install -g @dailin521/codex-provider-sync@latest + codex-provider --help + ``` + +Trusted Publisher 配置: + +- GitHub 用户或组织:`Dailin521` +- 仓库:`codex-provider-sync` +- 工作流文件:`publish-npm.yml` +- Environment:`npm` +- Allowed actions:`npm publish` + +## 手动备用流程 + +只有 Trusted Publisher 暂时不可用时,才由 npm 组织 owner 在可信本机手动发布: + +```bash +npm login +npm whoami +npm run publish:npm -- --dry-run +npm run publish:npm +``` + +发布脚本会构建 Web UI、运行 Node 测试、预览包内容,再以公开包发布。不要把 npm token、密码、恢复码或一次性验证码写入仓库、Issue、PR 或日志。 diff --git a/docs/README_EN.md b/docs/README_EN.md index 837491d..954005a 100644 --- a/docs/README_EN.md +++ b/docs/README_EN.md @@ -2,180 +2,176 @@ # codex-provider-sync -### Keep Codex history visible after switching Providers +### Make Codex history visible again after switching providers [![CI](https://github.com/Dailin521/codex-provider-sync/actions/workflows/ci.yml/badge.svg)](https://github.com/Dailin521/codex-provider-sync/actions/workflows/ci.yml) -[![Release](https://img.shields.io/github/v/release/Dailin521/codex-provider-sync)](https://github.com/Dailin521/codex-provider-sync/releases/latest) +[![CLI / Web](https://img.shields.io/npm/v/%40dailin521%2Fcodex-provider-sync?label=CLI%20%2F%20Web)](https://www.npmjs.com/package/@dailin521/codex-provider-sync) +[![Windows GUI](https://img.shields.io/github/v/release/Dailin521/codex-provider-sync?label=Windows%20GUI)](https://github.com/Dailin521/codex-provider-sync/releases/latest) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](../LICENSE) +[![Community](https://img.shields.io/badge/community-LINUX%20DO-2ea043.svg)](https://linux.do/) -[Download Windows GUI](https://github.com/Dailin521/codex-provider-sync/releases/latest) · [Build macOS GUI](README_MAC_GUI_EN.md) · [中文](../README.md) · English +[中文](../README.md) · **English** · [日本語](README_JA.md) · [한국어](README_KO.md) -## When You Need It +## What it solves -After switching `model_provider`, older Codex sessions may disappear from Desktop or `/resume`. The sessions are usually still present, but their rollout, SQLite, or project-visibility metadata still points to the previous Provider. +After switching `model_provider`, older sessions may disappear from Codex Desktop or `/resume`. **The data usually remains on disk**; only the provider information in session files and the SQLite index is out of sync. -Use this tool when: +This tool synchronizes session files and the SQLite index, restoring session visibility and creating a backup before writing. It does not sign in, switch accounts, or modify `auth.json` or message content. -- switching between an official subscription (whose internal Provider is `openai`) and a custom relay; -- switching configurations that must use different `model_provider` IDs; -- rollout and SQLite Provider or model metadata has become inconsistent; or -- you want changes to `config.toml`, SQLite, or its WAL to trigger synchronization automatically. +

+ Provider metadata before and after synchronization +

-If all of your relays can reliably reuse one `model_provider` ID and history remains visible, using that shared ID is simpler and no synchronization is needed. This project is mainly useful when Provider IDs cannot be unified or when switching between official and custom Providers. +### When is synchronization needed? -The tool does not sign in, manage accounts, or switch authentication. Switch Provider using your normal workflow first, then synchronize history. +- **Typical case:** Switching between official OpenAI and a custom relay. Official OpenAI always uses `openai`, so the Provider ID changes and history needs to be synchronized. +- **Existing mixed history:** Older sessions already contain different Provider IDs and need to be aligned with the current provider. +- **No synchronization needed:** Switching only among custom relays that share one Provider ID, or when CCSwitch or another tool has already synchronized history. -## Relationship to Provider Switchers +## Quick Start -Provider managers, including cc-switch, primarily switch accounts, API keys, `auth.json`, or `config.toml`; some also provide their own history handling. codex-provider-sync deliberately leaves authentication alone and focuses on post-switch visibility metadata, rollout files, SQLite state, managed backups, and restoration. +> The Windows GUI and Local Web UI currently use a Simplified Chinese interface. +> +> CLI/Web and the Windows GUI are released independently, so their version numbers may differ. -If your current switcher already keeps all history visible, you do not need to run another synchronization. This project remains useful when several switching workflows have split existing history, rollout and SQLite need to be reconciled together, SQLite Home is separate from Codex Home, or backup-backed transactional repair is required. +| Scenario | Recommended interface | +| --- | --- | +| Windows desktop | [Download Windows GUI](https://github.com/Dailin521/codex-provider-sync/releases/latest) · [Usage guide](#windows-gui) | +| macOS desktop | [Local Web UI (CLI required)](#local-web-ui); [native GUI build guide](README_MAC_GUI_EN.md) | +| Browser interface or cross-platform use | [Local Web UI (CLI required)](#local-web-ui) | +| Scripts, CI, or WSL | [CLI](#cli) | -## What It Updates +### Windows GUI -- Rollout metadata under `~/.codex/sessions` and `~/.codex/archived_sessions`. -- Codex SQLite thread records, including layouts where SQLite is stored outside Codex Home. -- Project-visibility path information and related model metadata when required. -- Managed backups before each synchronization, with restore and pruning support. -- Large rollout files in place when safe, with automatic fallback to a full safe rewrite. -- Automatic CLI synchronization after `config.toml`, SQLite, or WAL changes. +Download `CodexProviderSync.exe` from [Releases](https://github.com/Dailin521/codex-provider-sync/releases/latest): -## Quick Start +1. Click `刷新` (Refresh). +2. Select the target provider. +3. Click `立即同步` (Sync Now). -### Windows GUI +The application is not code-signed, so Windows may show a security warning. Download only from this project's Releases. + +[Full Windows GUI guide (Chinese)](README_GUI_ZH.md) + +### Local Web UI -For normal Windows use, download the standalone GUI from [Releases](https://github.com/Dailin521/codex-provider-sync/releases/latest): +The Local Web UI is provided by the CLI. Install Node.js `16.20.2+`, then install this project's official npm package and start it: -| Use case | Release asset | Update method | -| --- | --- | --- | -| Windows GUI only | `CodexProviderSync.exe` | Built-in updates supported | -| Scripts, CI, or AI agents | `codex-provider-sync-v-automation-win-x64.zip` | Manual update | -| GUI and Automation together | `codex-provider-sync-v-win-x64.zip` | Manual update | +```bash +npm install -g @dailin521/codex-provider-sync +codex-provider web +``` + +

+ Web UI overview +

+ +Common options: + +```bash +codex-provider web --no-open # Do not open a browser automatically +codex-provider web --port 8792 # Use a specific port +codex-provider web --reset-access # Pair a browser again +``` -1. Open `CodexProviderSync.exe`. -2. Click `刷新` (Refresh). -3. Select the target Provider. -4. Click `立即同步` (Sync Now). +The Web UI listens on `127.0.0.1` by default and opens a browser to pair automatically. Storage paths are managed by the storage configuration (profile) at the top of the page. Write operations require confirmation. -The GUI keeps backups and displays the synchronization result. It checks for a stable release in the background on the first launch of each local day, with a 10-second lookup deadline. Manual update checks remain available. Execution logs are stored under `%AppData%\codex-provider-sync\logs`. +#### Synchronize history after switching providers -The Windows GUI supports a separate SQLite Home on the Windows filesystem for each Codex Home. WSL UNC paths such as `\\wsl.localhost\...` and `\\wsl$\...` are diagnostic-only; the GUI reports the safety boundary and disables synchronization and restore. Run the CLI inside WSL for a Windows Codex Home plus WSL SQLite Home layout. +1. Switch providers with CCSwitch or your usual tool. +2. Click `读取状态` (Read Status) in the Web UI if needed (optional). +3. Keep `仅同步元数据` (Metadata Only), select the target provider, and confirm the sync. +4. You are done when the page shows `Provider 元数据已对齐` (Provider Metadata Aligned). -The Windows executable is currently unsigned, so browser downloads may trigger a SmartScreen warning. Download it only from this project's Releases and verify the matching SHA-256 when needed. +> **Note:** Metadata sync restores history visibility only. When continuing an old session across providers, the target backend may be unable to decrypt its `encrypted_content` reasoning data, causing continuation or compaction to fail. -See [README_GUI_ZH.md](README_GUI_ZH.md) for the full Windows guide. A self-built Avalonia macOS app is also available; see the [English macOS GUI guide](README_MAC_GUI_EN.md). +[Full Web UI guide (Chinese)](README_WEB_UI_ZH.md) ### CLI -The CLI requires Node.js `16+`: +The CLI supports Node.js `16.20.2+`. After installing Node.js, install this project's official npm package: ```bash -npm install -g git+https://github.com/Dailin521/codex-provider-sync.git +npm install -g @dailin521/codex-provider-sync codex-provider status codex-provider sync ``` -Common commands: - | Command | Purpose | | --- | --- | -| `codex-provider status` | Inspect the current Provider, rollout files, SQLite, and project visibility | -| `codex-provider sync` | Synchronize history to the current Provider without changing authentication | -| `codex-provider switch ` | Change the root `model_provider`, then synchronize | -| `codex-provider restore ` | Restore a selected backup | -| `codex-provider prune-backups --keep 5` | Keep only the five newest managed backups | -| `codex-provider watch` | Watch config, SQLite, and WAL changes and synchronize automatically | -| `codex-provider watch --once` | Exit after the first change is synchronized successfully | - -`switch` accepts `--model ` to set the root model explicitly, or `--keep-root-model` to change only the Provider. All main commands accept `--codex-home ` and `--sqlite-home `. +| `codex-provider status` | Inspect provider, rollout, and SQLite state | +| `codex-provider sync` | Synchronize to the current provider | +| `codex-provider switch ` | Switch provider, then synchronize | +| `codex-provider restore ` | Restore a backup | +| `codex-provider watch` | Watch configuration and SQLite changes | -SQLite Home precedence is: CLI override, root-level `sqlite_home` in `config.toml`, `CODEX_SQLITE_HOME`, then `/sqlite`. The legacy `/state_5.sqlite` fallback is enabled only for the default layout. An explicit SQLite Home never falls back to a stale database under Codex Home. +By default, `switch` also updates the root-level `model` when the target provider section defines one. Use `--keep-root-model` to preserve the current value, or `--model ` to set it explicitly. -For a Windows Codex Home with app-server and SQLite running in WSL, invoke the CLI from WSL: +SQLite Home resolution order: `--sqlite-home` → root-level `sqlite_home` in `config.toml` → `CODEX_SQLITE_HOME` → `/sqlite`. Only the default layout falls back to `/state_5.sqlite`. -```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 -``` +## Current Architecture -`status` reports the effective SQLite Home and its source. If an explicit location has no `state_5.sqlite`, read-only status reports the diagnostic while write operations fail. If a database is deleted from the default layout, `restore` can rebuild it at its original default location from backup metadata. New metadata v2 backups record the separate SQLite Home. Restoring a v2 backup to a different SQLite Home is rejected unless relocation is explicitly confirmed; the CLI requires `--sqlite-home`, `--allow-sqlite-home-relocation`, and `--no-config` so the restored config cannot point Codex back to the source SQLite Home. +```mermaid +flowchart LR + Browser["Browser Web UI"] --> WebServer["Local Node Web Server
127.0.0.1"] + WebServer --> NodeService["Node Service"] + CLI["Node CLI"] --> NodeService -Node.js 24+ uses the built-in `node:sqlite` module. Older supported Node.js releases use the optional `better-sqlite3` dependency. + WindowsGUI["Windows GUI"] --> Application[".NET Application"] + Application --> DotNetCore[".NET Core"] + MacGUI["macOS GUI"] --> DotNetCore -### Automation API (experimental v0.4) + NodeService --> Storage["Codex Storage"] + DotNetCore --> Storage -Releases provide a separate Windows Automation package containing `CodexProviderSync.Automation.exe`, `automation-protocol-v0.4.schema.json`, and a Chinese quick start. The complete Windows package contains the same files. This one-shot process interface uses the same Application use cases as the Windows GUI. Each invocation emits exactly one protocol `0.4` JSON document on stdout and sends diagnostics to stderr. Normal desktop users do not need the Automation package. - -| Command | Purpose | -| --- | --- | -| `describe` | Describe protocol capabilities and safety requirements | -| `status` | Read status and diagnostics | -| `plan --operation sync\|switch\|restore\|prune` | Create a plan for a selected write operation | -| `sync` | Plan or explicitly apply synchronization | -| `switch` | Plan or explicitly apply a Provider/model switch and synchronization | -| `restore` | Plan or explicitly apply backup restoration | -| `prune` | Plan or explicitly prune managed backups | - -Every write command is dry-run by default and returns a plan without modifying a target. Mutation requires `--apply`, a plan file containing only the `data` object from the `plan` response, and that object's exact lowercase SHA-256 `digest`: - -```powershell -.\CodexProviderSync.Automation.exe describe -.\CodexProviderSync.Automation.exe status --codex-home C:\isolated\.codex -.\CodexProviderSync.Automation.exe sync --codex-home C:\isolated\.codex --provider openai -$planResponse = .\CodexProviderSync.Automation.exe plan --operation sync --codex-home C:\isolated\.codex --provider openai | ConvertFrom-Json -$planResponse.data | ConvertTo-Json -Depth 100 -Compress | Set-Content -LiteralPath C:\isolated\sync-plan.json -Encoding utf8NoBOM -$planDigest = $planResponse.data.digest -.\CodexProviderSync.Automation.exe sync --codex-home C:\isolated\.codex --provider openai --apply --plan C:\isolated\sync-plan.json --plan-digest $planDigest + Storage --> Config["config.toml"] + Storage --> Rollouts["sessions / archived_sessions"] + Storage --> SQLite["state_5.sqlite"] + Storage --> Backups["managed backups"] ``` -Plans expire, bind normalized inputs and target state, and are single-use through a durable ledger. The default ledger is `\tmp\provider-sync-automation-ledger`. Every path argument must be absolute and may not traverse a symbolic link or reparse point. Automation also rejects direct access to `auth.json`. The protocol remains experimental before 1.0; compatibility is not promised outside protocol family `0.4`. +- The Web UI and CLI share the same Node service logic. +- The Windows GUI calls .NET Core through the Application layer; the macOS GUI currently calls .NET Core directly. +- The Node service and .NET Core enforce the same configuration, rollout, SQLite, and backup safety boundaries. -## Safety and Limitations +## Safety boundaries -Before each `sync` or `switch`, the tool creates a backup under: - -```text -~/.codex/backups_state/provider-sync/ -``` - -- It does not modify messages, session titles, authentication, `auth.json`, or `updated_at`. -- It does not copy configuration or session files between devices; it only repairs metadata in the current Codex Home. -- If SQLite is in use, close Codex, Codex App, and app-server before retrying. -- A Windows process that resolves SQLite Home through a WSL UNC path reports a dedicated safety diagnostic and stops immediately. Continue inside that WSL distribution with the Linux `/home/...` path. -- If a live session locks a rollout file, the tool skips that file and continues. Run sync again after the session ends for a complete update. -- Sessions containing `encrypted_content` may become visible across Providers/accounts but still fail to continue or compact with `invalid_encrypted_content`. -- Codex Desktop currently shows only the latest 50 sessions on its first page. If `/resume` can see a session but the project view cannot, inspect the `first page` / `ranks` diagnostics. This tool does not alter timestamps to bypass that upstream limit. +- Before every `sync` or `switch`, a backup is created at `/backups_state/provider-sync/`; with the default Codex Home, this is `~/.codex/backups_state/provider-sync/`. +- Does not modify message content, session titles, authentication data, `auth.json`, or `updated_at`. +- If SQLite is in use, close Codex, Codex App, and app-server, then retry. +- If an active session locks rollout files, other files continue; sync again after that session ends. +- When continuing an old session across providers or accounts, the target backend may be unable to decrypt `encrypted_content`, causing continuation or compaction to fail. Return to the original provider/account or start a new session. +- Windows cannot write directly to a WSL UNC SQLite Home; enter WSL and run the CLI with Linux paths. ## Documentation -- [Windows GUI guide](README_GUI_ZH.md) -- [macOS GUI guide](README_MAC_GUI_EN.md) -- [v0.4.0 Chinese release announcement](release-notes/v0.4.0-zh.md) -- [v0.4.0 technical release notes](RELEASE_NOTES_V0.4.0.md) -- [Changelog](../CHANGELOG.md) -- [Chinese Automation quick start](AUTOMATION_QUICKSTART_ZH.md) -- [v0.4 Automation execution plan](V0.4_AUTOMATION_PLAN.md) -- [中文说明](../README.md) -- [AI / Agent guide](../AGENTS.md) -- [Contributing guide](../CONTRIBUTING.md#english-quick-guide) +- [AI / Agent Guide](../AGENTS.md) +- [Windows GUI guide (Chinese)](README_GUI_ZH.md) +- [Web UI guide (Chinese)](README_WEB_UI_ZH.md) +- [中文](../README.md) · [日本語](README_JA.md) · [한국어](README_KO.md) +- [macOS GUI: 中文](README_MAC_GUI_ZH.md) · [English](README_MAC_GUI_EN.md) +- [How it works (Chinese)](WORKING_PRINCIPLE_ZH.md) · [Changelog](../CHANGELOG.md) · [Contributing](../CONTRIBUTING.md) ## Development ```bash -git clone https://github.com/Dailin521/codex-provider-sync.git -cd codex-provider-sync +npm ci +npm run web:build +npm run web:start npm test dotnet test desktop/CodexProviderSync.Core.Tests/CodexProviderSync.Core.Tests.csproj -./scripts/test-wsl-unc-safety.sh -pwsh ./scripts/publish-gui.ps1 -pwsh ./scripts/run-windows-gui-e2e.ps1 -./scripts/publish-gui-macos.sh ``` -Run `test-wsl-unc-safety.sh` from WSL. It invokes Windows `dotnet.exe` to verify the safety guard against a real SQLite database on WSL ext4. Run `run-windows-gui-e2e.ps1` only on a visible, interactive Windows desktop. The v0.4 implementation commit `7545b5d` passed this gate with 40/40 manifest entries covered, 53/53 required scenarios passed, and zero errors or blockers; the evidence gate also verified the published EXE hash, real control events, native dialogs, file/SQLite effects, restart persistence, and GUI-to-Application traces. Relevant later implementation changes require another run. Hidden, skipped, or direct-Application runs are not substitutes. +Maintainers can publish the CLI/Web package independently of Windows GUI releases. See the [npm publishing guide (Chinese)](NPM_PUBLISHING.md). + +## Acknowledgements + +Thanks to [@tangquanwei](https://github.com/tangquanwei) for proposing and implementing the Local Web UI, contributing history browsing and the multilingual documentation foundation, and bringing it into v0.5.0 through [PR #80](https://github.com/Dailin521/codex-provider-sync/pull/80), and to everyone who has contributed code, documentation, testing, and investigation. + +[Contributor list](../CONTRIBUTORS.md) · [GitHub Contributors](https://github.com/Dailin521/codex-provider-sync/graphs/contributors) ## License -MIT +[MIT](../LICENSE) diff --git a/docs/README_GUI_ZH.md b/docs/README_GUI_ZH.md index 3a516a2..0690d54 100644 --- a/docs/README_GUI_ZH.md +++ b/docs/README_GUI_ZH.md @@ -38,9 +38,9 @@ macOS 桌面版说明见 [README_MAC_GUI_ZH.md](README_MAC_GUI_ZH.md)。 - 含 `encrypted_content` 的旧会话不能由本工具重新加密到另一个 provider / account - 如果 CLI 能看到历史会话但 Desktop 项目侧仍不显示,请优先复制并反馈“刷新”后的完整状态文本 -## Codex Desktop 最近 50 条限制 +## 项目可见性诊断 -Codex Desktop 当前首屏只拉取最近 `50` 条会话。如果某个项目的旧会话排在全局最近 50 条之后,CLI `/resume` 可能能看到,但 Desktop 项目侧仍显示“暂无对话”。 +工具会诊断全局排序中的前 `50` 条会话。某个项目的旧会话如果不在这段范围内,CLI `/resume` 可能能看到,但 Desktop 项目侧仍可能暂时不显示。 GUI“刷新”会显示项目可见性诊断,例如 `first page 0/50`、`ranks 64-77`。这表示会话存在,但没有进入 Desktop 首屏最近 50 条。本工具不会修改 `updated_at` 或历史排序来绕过这个限制。 @@ -65,21 +65,9 @@ Windows GUI 将 `\\wsl.localhost\<发行版>\...` 和 `\\wsl$\<发行版>\...` 对于受支持的 Windows 本地路径,显式位置缺少 `state_5.sqlite` 时,“刷新”会显示缺库诊断,写操作会停止,并保持此显式路径作为唯一目标。默认布局中的数据库被删除时,可以从有效备份恢复到原默认位置。从 metadata v2 备份恢复到不同 SQLite Home 时,必须取消勾选“恢复配置文件”;GUI 随后会显示来源与目标并要求二次确认。 -## v0.4 Application 与 GUI Automation(开发者) +## 开发与 Automation -Windows GUI 的状态、同步、切换、恢复和备份清理入口通过共享的 Application 用例调用 Core;同一组用例也供 `CodexProviderSync.Automation.exe` 的 Business API 使用。WinForms 仍负责控件状态、原生文件夹选择器、确认框、更新提示和其它 Windows 平台交互。 - -测试专用 GUI Automation 为全部交互入口分配稳定 Automation ID,并由 `Automation/gui-automation-manifest.v0.4.json` 声明控件、动作、风险和场景。桥接器只在带 sentinel 的隔离启动中启用,通过 current-user-only 随机 named pipe 和一次性 token 接受 `ui.describe`、`ui.snapshot`、`ui.get`、`ui.set`、`ui.invoke`、`ui.wait`、`ui.shutdown`。`ui.set` / `ui.invoke` 操作真实控件并触发真实 WinForms 事件,不允许绕过 GUI 直接调用 Application 后冒充控件覆盖;trace schema 2 会关联 Automation ID、GUI 事件和 Application operation/lifecycle。 - -普通启动不会创建 Automation listener。隔离环境拒绝真实 Codex Home、`auth.json`、越界路径、符号链接或 reparse point;原生对话框由外部 Headful 驱动器在可见桌面上操作,不提供生产控制接口。 - -真实 Windows Release GUI 全入口 E2E 的运行入口是: - -```powershell -pwsh ./scripts/run-windows-gui-e2e.ps1 -``` - -它必须在可见、可交互的 Windows 桌面中运行。Release gate 的验收条件包括:发布后的真实 `CodexProviderSync.exe`、manifest 全入口/动作覆盖、真实控件与事件、原生对话框、状态与 busy 行为、独立文件/SQLite 差异、重启持久化,以及 GUI → Application 因果 trace。v0.4 的实现提交 `7545b5d` 已在真实可见桌面通过:40/40 manifest 入口覆盖、53/53 必需场景通过、0 error、0 blocker;prune 证据还逐目录确认旧 managed backup 消失并校验 unmanaged sentinel 哈希未变。单元测试、隐藏窗口、mock 或直接调用 Application 都不能记作真实 GUI PASS,相关实现变更后必须重跑。 +Windows Release 包含实验性的 `CodexProviderSync.Automation.exe`,仅用于隔离测试和开发,不是生产 GUI 控制接口。快速使用见 [Automation Quickstart](AUTOMATION_QUICKSTART_ZH.md),协议、安全边界和真实 GUI E2E 要求见 [Automation Design Notes](AUTOMATION_DESIGN_NOTES.md)。 ## 更新与日志 @@ -94,7 +82,7 @@ Windows GUI 每天首次启动会在后台检查一次最新的稳定版 GitHub - GUI 设置:`%AppData%\codex-provider-sync\settings.json` - 每日执行日志:`%AppData%\codex-provider-sync\logs\execution-YYYY-MM-DD.log` - 启动失败日志:`%AppData%\codex-provider-sync\startup-error.log` -- 备份目录:`%USERPROFILE%\.codex\backups_state\provider-sync\` +- 备份目录:`\backups_state\provider-sync\`;默认 Codex Home 时为 `%USERPROFILE%\.codex\backups_state\provider-sync\` ## 注意事项 diff --git a/docs/README_JA.md b/docs/README_JA.md new file mode 100644 index 0000000..b896338 --- /dev/null +++ b/docs/README_JA.md @@ -0,0 +1,177 @@ +
+ +# codex-provider-sync + +### Provider 切り替え後も Codex の過去セッションを再表示する + +[![CI](https://github.com/Dailin521/codex-provider-sync/actions/workflows/ci.yml/badge.svg)](https://github.com/Dailin521/codex-provider-sync/actions/workflows/ci.yml) +[![CLI / Web](https://img.shields.io/npm/v/%40dailin521%2Fcodex-provider-sync?label=CLI%20%2F%20Web)](https://www.npmjs.com/package/@dailin521/codex-provider-sync) +[![Windows GUI](https://img.shields.io/github/v/release/Dailin521/codex-provider-sync?label=Windows%20GUI)](https://github.com/Dailin521/codex-provider-sync/releases/latest) +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](../LICENSE) +[![Community](https://img.shields.io/badge/community-LINUX%20DO-2ea043.svg)](https://linux.do/) + +[中文](../README.md) · [English](README_EN.md) · **日本語** · [한국어](README_KO.md) + +
+ +## 解決すること + +`model_provider` を切り替えた後、既存セッションが Codex Desktop や `/resume` から消えることがあります。**データ自体は通常ディスク上に残っています**。セッションファイルと SQLite インデックス内の Provider 情報だけが同期されていません。 + +このツールはセッションファイルと SQLite インデックスを同期してセッションの可視性を復元し、書き込み前にバックアップを作成します。ログイン、アカウント切り替え、`auth.json`、メッセージ本文は扱いません。 + +

+ Provider メタデータ同期の前後 +

+ +### 同期が必要なのはいつですか? + +- **通常のケース:**公式 OpenAI とカスタム中継先を切り替える場合。公式 OpenAI は常に `openai` を使用するため Provider ID が変わり、履歴の同期が必要です。 +- **既存履歴で ID が混在している場合:**旧セッションに異なる Provider ID が記録されているため、現在の Provider に揃える必要があります。 +- **同期が不要なケース:**同じ Provider ID を共有するカスタム中継先だけを切り替える場合、または CCSwitch などがすでに履歴を同期している場合です。 + +## クイックスタート + +> Windows GUI とローカル Web UI の画面表示は現在、簡体字中国語のみです。 +> +> CLI/Web と Windows GUI は別々にリリースされるため、バージョン番号が異なる場合があります。 + +| 利用場面 | 推奨する入口 | +| --- | --- | +| Windows デスクトップ | [Windows GUI をダウンロード](https://github.com/Dailin521/codex-provider-sync/releases/latest)・[使い方](#windows-gui) | +| macOS デスクトップ | [ローカル Web UI(CLI が必要)](#ローカル-web-ui)・[ネイティブ GUI のビルド手順(英語)](README_MAC_GUI_EN.md) | +| ブラウザ UI またはクロスプラットフォーム利用 | [ローカル Web UI(CLI が必要)](#ローカル-web-ui) | +| スクリプト、CI、または WSL | [CLI](#cli) | + +### Windows GUI + +[Releases](https://github.com/Dailin521/codex-provider-sync/releases/latest) から `CodexProviderSync.exe` をダウンロードします。 + +1. 「刷新」(Refresh)をクリックします。 +2. 対象の Provider を選択します。 +3. 「立即同步」(Sync Now)をクリックします。 + +コード署名は付与していないため、Windows でセキュリティ警告が表示される場合があります。本プロジェクトの Releases からのみダウンロードしてください。 + +[Windows GUI の詳細(中国語)](README_GUI_ZH.md) + +### ローカル Web UI + +ローカル Web UI は CLI に含まれています。Node.js `16.20.2+` をインストールし、本プロジェクトの公式 npm パッケージをインストールして起動します。 + +```bash +npm install -g @dailin521/codex-provider-sync +codex-provider web +``` + +

+ Web UI 概要 +

+ +よく使うオプション: + +```bash +codex-provider web --no-open # ブラウザを自動で開かない +codex-provider web --port 8792 # ポートを指定する +codex-provider web --reset-access # ブラウザを再ペアリングする +``` + +Web UI はデフォルトで `127.0.0.1` のみで待ち受け、ブラウザを自動で開いてペアリングします。保存先はページ上部の保存設定(Profile)で管理します。書き込み操作には確認が必要です。 + +#### Provider 切り替え後に履歴を同期する + +1. CCSwitch など普段使用しているツールで Provider を切り替えます。 +2. 必要に応じて Web UI で「读取状态」(Read Status)をクリックします(省略可)。 +3. 「仅同步元数据」(Metadata Only)のまま対象 Provider を選択し、同期の実行を確認します。 +4. 「Provider 元数据已对齐」(Provider Metadata Aligned)が表示されれば完了です。 + +> **注意:** メタデータ同期で復元されるのは履歴の可視性だけです。Provider をまたいで旧セッションを続行すると、切り替え先のバックエンドが `encrypted_content` の推論内容を復号できず、続行や compact に失敗する場合があります。 + +[Web UI の詳細(中国語)](README_WEB_UI_ZH.md) + +### CLI + +CLI は Node.js `16.20.2+` をサポートします。Node.js のインストール後、本プロジェクトの公式 npm パッケージをインストールします。 + +```bash +npm install -g @dailin521/codex-provider-sync +codex-provider status +codex-provider sync +``` + +| コマンド | 用途 | +| --- | --- | +| `codex-provider status` | Provider、rollout、SQLite の状態を確認する | +| `codex-provider sync` | 現在の Provider に同期する | +| `codex-provider switch ` | Provider を切り替えてから同期する | +| `codex-provider restore ` | バックアップを復元する | +| `codex-provider watch` | 設定と SQLite の変更を監視する | + +`switch` は、対象 Provider section に `model` が定義されている場合、デフォルトでルートレベルの `model` も更新します。現在の値を保持するには `--keep-root-model`、明示的に指定するには `--model ` を使用します。 + +SQLite Home の解決順序: `--sqlite-home` → `config.toml` ルートの `sqlite_home` → `CODEX_SQLITE_HOME` → `/sqlite`。デフォルトレイアウトだけが `/state_5.sqlite` にフォールバックします。 + +## 現在のアーキテクチャ + +```mermaid +flowchart LR + Browser["Browser Web UI"] --> WebServer["Local Node Web Server
127.0.0.1"] + WebServer --> NodeService["Node Service"] + CLI["Node CLI"] --> NodeService + + WindowsGUI["Windows GUI"] --> Application[".NET Application"] + Application --> DotNetCore[".NET Core"] + MacGUI["macOS GUI"] --> DotNetCore + + NodeService --> Storage["Codex Storage"] + DotNetCore --> Storage + + Storage --> Config["config.toml"] + Storage --> Rollouts["sessions / archived_sessions"] + Storage --> SQLite["state_5.sqlite"] + Storage --> Backups["managed backups"] +``` + +- Web UI と CLI は同じ Node サービスロジックを使用します。 +- Windows GUI は Application 層を通じて .NET Core を呼び出し、macOS GUI は現在 .NET Core を直接呼び出します。 +- Node サービスと .NET Core は同じ設定、rollout、SQLite、バックアップの安全境界を扱います。 + +## 安全上の境界 + +- `sync` / `switch` の前に、毎回 `/backups_state/provider-sync/` へバックアップします。デフォルトの Codex Home では `~/.codex/backups_state/provider-sync/` です。 +- メッセージ本文、セッションタイトル、認証情報、`auth.json`、`updated_at` は変更しません。 +- SQLite が使用中の場合は、Codex、Codex App、app-server を閉じてから再試行してください。 +- アクティブなセッションが rollout をロックしている場合、他のファイルは続行します。セッション終了後にもう一度同期してください。 +- Provider またはアカウントをまたいで旧セッションを続行すると、切り替え先のバックエンドが `encrypted_content` を復号できず、続行や compact に失敗する場合があります。その場合は元の Provider/アカウントに戻すか、新しいセッションを開始してください。 +- Windows から WSL UNC SQLite Home に直接書き込むことはできません。WSL に入り、Linux パスで CLI を実行してください。 + +## ドキュメント + +- [AI / Agent ガイド](../AGENTS.md) +- [Windows GUI(中国語)](README_GUI_ZH.md) +- [Web UI(中国語)](README_WEB_UI_ZH.md) +- [中文](../README.md) · [English](README_EN.md) · 日本語 · [한국어](README_KO.md) +- [macOS GUI: 中文](README_MAC_GUI_ZH.md) · [English](README_MAC_GUI_EN.md) +- [仕組み(中国語)](WORKING_PRINCIPLE_ZH.md) · [変更履歴](../CHANGELOG.md) · [コントリビューションガイド](../CONTRIBUTING.md) + +## 開発 + +```bash +npm ci +npm run web:build +npm run web:start +npm test +dotnet test desktop/CodexProviderSync.Core.Tests/CodexProviderSync.Core.Tests.csproj +``` + +メンテナーは、Windows GUI の Release とは独立して CLI/Web パッケージを公開できます。[npm 公開ガイド(中国語)](NPM_PUBLISHING.md)を参照してください。 + +## 謝辞 + +ローカル Web UI を提案・実装し、履歴閲覧機能と多言語ドキュメントの基盤を提供するとともに、[PR #80](https://github.com/Dailin521/codex-provider-sync/pull/80) を通じて v0.5.0 に導入した [@tangquanwei](https://github.com/tangquanwei)、そしてコード、ドキュメント、テスト、問題調査に貢献したすべての方に感謝します。 + +[コントリビューター一覧](../CONTRIBUTORS.md) · [GitHub Contributors](https://github.com/Dailin521/codex-provider-sync/graphs/contributors) + +## License + +MIT diff --git a/docs/README_KO.md b/docs/README_KO.md new file mode 100644 index 0000000..47e6d81 --- /dev/null +++ b/docs/README_KO.md @@ -0,0 +1,177 @@ +
+ +# codex-provider-sync + +### Provider 전환 후 Codex의 이전 세션을 다시 표시합니다 + +[![CI](https://github.com/Dailin521/codex-provider-sync/actions/workflows/ci.yml/badge.svg)](https://github.com/Dailin521/codex-provider-sync/actions/workflows/ci.yml) +[![CLI / Web](https://img.shields.io/npm/v/%40dailin521%2Fcodex-provider-sync?label=CLI%20%2F%20Web)](https://www.npmjs.com/package/@dailin521/codex-provider-sync) +[![Windows GUI](https://img.shields.io/github/v/release/Dailin521/codex-provider-sync?label=Windows%20GUI)](https://github.com/Dailin521/codex-provider-sync/releases/latest) +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](../LICENSE) +[![Community](https://img.shields.io/badge/community-LINUX%20DO-2ea043.svg)](https://linux.do/) + +[中文](../README.md) · [English](README_EN.md) · [日本語](README_JA.md) · **한국어** + +
+ +## 해결하는 문제 + +`model_provider`를 전환하면 이전 세션이 Codex Desktop 또는 `/resume`에서 사라질 수 있습니다. **데이터는 보통 디스크에 그대로 남아 있으며**, 세션 파일과 SQLite 인덱스의 Provider 정보만 동기화되지 않은 상태입니다. + +이 도구는 세션 파일과 SQLite 인덱스를 동기화하여 세션 표시를 복원하고, 쓰기 전에 백업을 만듭니다. 로그인이나 계정 전환은 처리하지 않으며 `auth.json`이나 메시지 본문도 수정하지 않습니다. + +

+ Provider 메타데이터 동기화 전후 +

+ +### 언제 동기화가 필요한가요? + +- **일반적인 경우:** 공식 OpenAI와 사용자 지정 릴레이 사이에서 전환합니다. 공식 OpenAI는 항상 `openai`를 사용하므로 Provider ID가 바뀌며 기록을 동기화해야 합니다. +- **기존 기록에 ID가 섞인 경우:** 이전 세션에 서로 다른 Provider ID가 기록되어 있으므로 현재 Provider에 맞춰야 합니다. +- **동기화가 필요 없는 경우:** 같은 Provider ID를 공유하는 사용자 지정 릴레이 사이에서만 전환하거나 CCSwitch 같은 도구가 이미 기록을 동기화한 경우입니다. + +## 빠른 시작 + +> Windows GUI와 로컬 Web UI의 화면 언어는 현재 중국어 간체만 지원합니다. +> +> CLI/Web과 Windows GUI는 별도로 릴리스되므로 버전 번호가 다를 수 있습니다. + +| 상황 | 권장 방법 | +| --- | --- | +| Windows 데스크톱 | [Windows GUI 다운로드](https://github.com/Dailin521/codex-provider-sync/releases/latest) / [사용 방법](#windows-gui) | +| macOS 데스크톱 | [로컬 Web UI (CLI 필요)](#로컬-web-ui) / [네이티브 GUI 빌드 안내 (영문)](README_MAC_GUI_EN.md) | +| 브라우저 UI 또는 크로스 플랫폼 사용 | [로컬 Web UI (CLI 필요)](#로컬-web-ui) | +| 스크립트, CI 또는 WSL | [CLI](#cli) | + +### Windows GUI + +[Releases](https://github.com/Dailin521/codex-provider-sync/releases/latest)에서 `CodexProviderSync.exe`를 다운로드합니다. + +1. `刷新`(새로 고침)을 클릭합니다. +2. 대상 Provider를 선택합니다. +3. `立即同步`(지금 동기화)를 클릭합니다. + +프로그램은 코드 서명되지 않았으므로 Windows에서 보안 경고가 표시될 수 있습니다. 이 프로젝트의 Releases에서만 다운로드하세요. + +[Windows GUI 전체 안내 (중국어)](README_GUI_ZH.md) + +### 로컬 Web UI + +로컬 Web UI는 CLI에 포함되어 있습니다. Node.js `16.20.2+`를 설치한 다음 이 프로젝트의 공식 npm 패키지를 설치하고 실행하세요. + +```bash +npm install -g @dailin521/codex-provider-sync +codex-provider web +``` + +

+ Web UI 개요 +

+ +자주 쓰는 옵션: + +```bash +codex-provider web --no-open # 브라우저를 자동으로 열지 않음 +codex-provider web --port 8792 # 포트 지정 +codex-provider web --reset-access # 브라우저 재페어링 +``` + +Web UI는 기본적으로 `127.0.0.1`에서만 수신하며, 브라우저를 자동으로 열어 페어링을 진행합니다. 저장 경로는 페이지 상단의 저장 구성(Profile)에서 관리하며 쓰기 작업에는 확인이 필요합니다. + +#### Provider 전환 후 기록 동기화 + +1. CCSwitch 등 평소 사용하는 도구로 Provider를 전환합니다. +2. 필요한 경우 Web UI에서 `读取状态`(상태 읽기)를 클릭합니다(생략 가능). +3. `仅同步元数据`(메타데이터만 동기화)를 유지한 채 대상 Provider를 선택하고 동기화 실행을 확인합니다. +4. `Provider 元数据已对齐`(Provider 메타데이터 정렬 완료)가 표시되면 완료입니다. + +> **주의:** 메타데이터 동기화는 기록의 표시만 복원합니다. Provider를 바꾼 뒤 이전 세션을 계속하면 대상 백엔드가 `encrypted_content`의 추론 내용을 복호화하지 못해 대화 계속 또는 compact가 실패할 수 있습니다. + +[Web UI 전체 안내 (중국어)](README_WEB_UI_ZH.md) + +### CLI + +CLI는 Node.js `16.20.2+`를 지원합니다. Node.js를 설치한 후 이 프로젝트의 공식 npm 패키지를 설치합니다. + +```bash +npm install -g @dailin521/codex-provider-sync +codex-provider status +codex-provider sync +``` + +| 명령 | 용도 | +| --- | --- | +| `codex-provider status` | Provider, rollout, SQLite 상태 확인 | +| `codex-provider sync` | 현재 Provider로 동기화 | +| `codex-provider switch ` | Provider 전환 후 동기화 | +| `codex-provider restore ` | 백업 복원 | +| `codex-provider watch` | 설정과 SQLite 변경 감시 | + +대상 Provider section에 `model`이 정의되어 있으면 `switch`는 기본적으로 루트 수준의 `model`도 업데이트합니다. 현재 값을 유지하려면 `--keep-root-model`, 명시적으로 지정하려면 `--model `을 사용하세요. + +SQLite Home 해석 순서: `--sqlite-home` → `config.toml` 루트의 `sqlite_home` → `CODEX_SQLITE_HOME` → `/sqlite`. 기본 레이아웃에서만 `/state_5.sqlite`로 대체합니다. + +## 현재 아키텍처 + +```mermaid +flowchart LR + Browser["Browser Web UI"] --> WebServer["Local Node Web Server
127.0.0.1"] + WebServer --> NodeService["Node Service"] + CLI["Node CLI"] --> NodeService + + WindowsGUI["Windows GUI"] --> Application[".NET Application"] + Application --> DotNetCore[".NET Core"] + MacGUI["macOS GUI"] --> DotNetCore + + NodeService --> Storage["Codex Storage"] + DotNetCore --> Storage + + Storage --> Config["config.toml"] + Storage --> Rollouts["sessions / archived_sessions"] + Storage --> SQLite["state_5.sqlite"] + Storage --> Backups["managed backups"] +``` + +- Web UI와 CLI는 동일한 Node 서비스 로직을 사용합니다. +- Windows GUI는 Application 계층을 통해 .NET Core를 호출하고, macOS GUI는 현재 .NET Core를 직접 호출합니다. +- Node 서비스와 .NET Core는 동일한 설정, rollout, SQLite, 백업 안전 범위를 처리합니다. + +## 안전 범위 + +- 매 `sync` / `switch` 전 `/backups_state/provider-sync/`에 백업합니다. 기본 Codex Home에서는 `~/.codex/backups_state/provider-sync/`입니다. +- 메시지 본문, 세션 제목, 인증 정보, `auth.json`, `updated_at`은 수정하지 않습니다. +- SQLite가 사용 중이면 Codex, Codex App, app-server를 닫은 뒤 다시 시도하세요. +- 활성 세션이 rollout을 잠그면 나머지 파일은 계속 처리합니다. 세션 종료 후 다시 동기화하면 됩니다. +- Provider 또는 계정을 바꾼 뒤 이전 세션을 계속하면 대상 백엔드가 `encrypted_content`를 복호화하지 못해 대화 계속 또는 compact가 실패할 수 있습니다. 이 경우 원래 Provider/계정으로 돌아가거나 새 세션을 시작하세요. +- Windows에서는 WSL UNC SQLite Home에 직접 쓸 수 없습니다. WSL에서 Linux 경로로 CLI를 실행하세요. + +## 문서 + +- [AI / Agent 가이드](../AGENTS.md) +- [Windows GUI (중국어)](README_GUI_ZH.md) +- [Web UI (중국어)](README_WEB_UI_ZH.md) +- [中文](../README.md) · [English](README_EN.md) · [日本語](README_JA.md) +- [macOS GUI: 中文](README_MAC_GUI_ZH.md) · [English](README_MAC_GUI_EN.md) +- [작동 원리 (중국어)](WORKING_PRINCIPLE_ZH.md) · [변경 이력](../CHANGELOG.md) · [기여 안내](../CONTRIBUTING.md) + +## 개발 + +```bash +npm ci +npm run web:build +npm run web:start +npm test +dotnet test desktop/CodexProviderSync.Core.Tests/CodexProviderSync.Core.Tests.csproj +``` + +유지관리자는 Windows GUI Release와 별도로 CLI/Web 패키지를 게시할 수 있습니다. [npm 게시 안내(중국어)](NPM_PUBLISHING.md)를 참조하세요. + +## 감사의 말 + +로컬 Web UI를 제안하고 구현했으며, 채팅 기록 탐색과 다국어 문서의 기반을 기여하고 [PR #80](https://github.com/Dailin521/codex-provider-sync/pull/80)을 통해 v0.5.0에 도입한 [@tangquanwei](https://github.com/tangquanwei), 그리고 코드, 문서, 테스트와 문제 조사에 기여한 모든 분께 감사드립니다. + +[기여자 목록](../CONTRIBUTORS.md) · [GitHub Contributors](https://github.com/Dailin521/codex-provider-sync/graphs/contributors) + +## License + +MIT diff --git a/docs/README_MAC_GUI_EN.md b/docs/README_MAC_GUI_EN.md index 9510e70..8699d9a 100644 --- a/docs/README_MAC_GUI_EN.md +++ b/docs/README_MAC_GUI_EN.md @@ -43,6 +43,7 @@ open artifacts/osx-arm64/CodexProviderSync.app ## Features - Select or enter a Codex Home; the default is `~/.codex`. +- Set a separate SQLite Home for each Codex Home, or leave it empty for automatic resolution. - Use `Refresh` to inspect the current Provider, rollout and SQLite Provider counts, managed backups, project visibility, and `encrypted_content` risks. - See where each Provider was discovered: `config`, `rollout`, `SQLite`, or @@ -62,7 +63,11 @@ open artifacts/osx-arm64/CodexProviderSync.app - The app asks for confirmation before write operations. - The Core creates a managed backup before `sync` or `switch` changes metadata. Backups are stored under - `~/.codex/backups_state/provider-sync/`. + `/backups_state/provider-sync/`; with the default + Codex Home this is `~/.codex/backups_state/provider-sync/`. +- Restoring a metadata v2 backup to a different SQLite Home shows the source + and target and requires another confirmation. Configuration restore must be + disabled for relocation. - The app does not manage `auth.json`, sign in, authenticate, change conversation content, or modify `updated_at`. - `encrypted_content` is reported as a risk; the app does not promise to repair @@ -81,6 +86,12 @@ Before using `Sync Metadata Only`, `Switch config.toml and sync`, If the app reports `state_5.sqlite is currently in use`, close those processes and retry. +SQLite Home resolution order is: GUI override → root `sqlite_home` in +`config.toml` → `CODEX_SQLITE_HOME` → `/sqlite`. Overrides are +stored per Codex Home in app settings and are not written to `config.toml`. +A missing explicit database remains an error and never falls back to another +location; only the default layout may use the legacy database path. + If the log reports skipped locked rollout files, an active session usually still holds those files open. Most of the synchronization may already be complete. Run sync again after the active session ends to update the skipped diff --git a/docs/README_MAC_GUI_ZH.md b/docs/README_MAC_GUI_ZH.md index d7cae77..dd1a32f 100644 --- a/docs/README_MAC_GUI_ZH.md +++ b/docs/README_MAC_GUI_ZH.md @@ -6,7 +6,7 @@ ## 构建 -需要 .NET 10 SDK。 +需要 .NET 10 SDK 和 macOS 12 或更高版本。 ```bash ./scripts/publish-gui-macos.sh diff --git a/docs/README_WEB_UI_ZH.md b/docs/README_WEB_UI_ZH.md new file mode 100644 index 0000000..da7a486 --- /dev/null +++ b/docs/README_WEB_UI_ZH.md @@ -0,0 +1,92 @@ +# Web UI 使用说明 + +Web UI 是 CLI 提供的本地浏览器界面,与 CLI 共用同一套同步、备份和恢复逻辑。 + +## 启动 + +Web UI 由 CLI 提供。安装 Node.js `16.20.2+` 后,安装本项目官方 npm 包并启动: + +```bash +npm install -g @dailin521/codex-provider-sync +codex-provider web +``` + +默认地址: + +```text +http://127.0.0.1:8791 +``` + +默认会打开系统浏览器。可使用: + +```bash +codex-provider web --port 8792 +codex-provider web --no-open +codex-provider web --reset-access +codex-provider web --codex-home /path/to/.codex --sqlite-home /path/to/sqlite +``` + +运行中的服务按 `Ctrl+C` 停止。升级已安装的 CLI/Web UI: + +```bash +npm install -g @dailin521/codex-provider-sync@latest +``` + +从仓库开发时也可以运行: + +```bash +npm ci +npm run web:build +npm run web:start +``` + +## 典型同步流程 + +本工具只同步本地元数据,不负责登录或切换账号。已经通过其他工具切换 Provider 时: + +1. 使用 CCSwitch 等常用工具切换 Provider,并确认 Codex 可以正常对话。 +2. 回到 Web UI;需要时点击“读取状态”。 +3. 在概览页的“执行同步”中保持“仅同步元数据”,选择目标 Provider(供应商),确认执行。 +4. 显示“Provider 元数据已对齐”即完成。切回原 Provider 时重复相同步骤。 + +rollout 与 SQLite 的会话总数可能因活动会话写入和索引时序短暂相差 1;这不表示 Provider 元数据未对齐。以两侧的 Provider 分布和页面对齐状态为准。 + +> **注意:** 元数据同步只能恢复历史可见性。跨供应商继续旧会话时,目标后端可能无法解密会话中的 `encrypted_content` 推理内容,导致继续对话或压缩(compact)失败。遇到这种情况请切回原 Provider/account,或新建会话。 + +## 页面功能 + +- 概览:显示当前 Provider、rollout/SQLite 分布、修复项和项目可见性。 +- 聊天记录:从 rollout 文件只读读取会话列表和用户/助手消息,支持搜索、Provider/项目/归档筛选、分页和会话详情。 +- 执行同步:区分“仅同步元数据”和“切换 Provider 并同步”。 +- 切换模型:支持跟随 Provider section、保留根级 model 或显式指定 model。 +- 备份:查看当前 Codex Home 下由本工具管理的备份,并按内容恢复。 +- 恢复保护:SQLite Home 不同时显示来源与目标;迁移数据库时禁止同时恢复旧配置。 +- 活动:显示当前 Web UI 进程内存中的同步阶段和操作结果;服务停止后不会作为日志文件保留。 +- 清理:按保留数量删除较旧的托管备份。 + +## 本地安全边界 + +- 服务只监听 `127.0.0.1`,不要直接暴露到局域网或公网。 +- 首次启动使用短时、一次性的配对链接;服务端只保存设备凭证哈希。使用“忘记此浏览器”或 `--reset-access` 可撤销授权。 +- 存储路径由服务端配置管理,写操作串行执行,恢复只能选择当前 Codex Home 下由本工具管理的备份。 +- Web UI 不能绕过共享核心逻辑中的锁、SQLite Home、WSL UNC、备份和恢复限制。 + +## SSH、无桌面和远程浏览器 + +服务不能直接暴露到公网或局域网。远程机器上使用时,只转发回环端口: + +```bash +ssh -L 8791:127.0.0.1:8791 user@server +``` + +在远程 shell 中启动: + +```bash +codex-provider web --no-open +``` + +命令会输出可点击的一次性配对链接。无桌面或纯 SSH 环境不会强制调用 `xdg-open`;即使浏览器打开失败,服务也会继续运行并输出配对链接。 + +## 注意事项 + +建议在同步或恢复前关闭 Codex CLI、Codex App、app-server 和相关终端。保持会话运行时,未锁定的数据仍可正常同步;如果报告跳过锁定的 rollout,操作属于部分成功,请在该会话结束后再次同步。SQLite 正在使用时,核心服务会在修改 rollout 前停止,此时必须关闭占用进程后重试。 diff --git a/docs/README_ZH.md b/docs/README_ZH.md index 0353ed9..d729bfe 100644 --- a/docs/README_ZH.md +++ b/docs/README_ZH.md @@ -1,7 +1,22 @@ -# 中文文档 +# codex-provider-sync 中文入口 -中文项目说明已统一维护在[主 README](../README.md),避免多份文档内容不同步。 +完整中文说明以仓库根目录的 [README.md](../README.md) 为准,包括适用场景、Windows GUI、可选本地 Web UI、CLI、SQLite Home 解析、安全限制和开发命令。 +专项文档: + +- [Web UI 中文指南](README_WEB_UI_ZH.md) - [Windows GUI 说明](README_GUI_ZH.md) -- [macOS GUI 说明](README_MAC_GUI_ZH.md) -- [English documentation](README_EN.md) +- [工作原理与落盘机制](WORKING_PRINCIPLE_ZH.md) +- macOS GUI:[中文](README_MAC_GUI_ZH.md) · [English](README_MAC_GUI_EN.md) + +开发与维护: + +- [贡献指南](../CONTRIBUTING.md) · [AI / Agent 操作指南](../AGENTS.md) +- [npm 发布维护指南](NPM_PUBLISHING.md) +- Automation:[快速开始](AUTOMATION_QUICKSTART_ZH.md) · [设计说明](AUTOMATION_DESIGN_NOTES.md) + +其他语言: + +- [English](README_EN.md) +- [日本語](README_JA.md) +- [한국어](README_KO.md) diff --git a/docs/RELEASE_NOTES_V0.5.0.md b/docs/RELEASE_NOTES_V0.5.0.md new file mode 100644 index 0000000..16dd081 --- /dev/null +++ b/docs/RELEASE_NOTES_V0.5.0.md @@ -0,0 +1,17 @@ +# v0.5.0 Technical Release Notes + +This is the npm release for the CLI and Local Web UI. The Windows GUI uses an independent GitHub Release channel and may have a different version; no v0.5.0 Windows GUI Release was created. + +v0.5.0 hardens the localhost Web UI shipped through PR #80. The browser now pairs through a high-entropy, short-lived, single-use URL fragment and receives a persistent device credential. The service persists only credential hashes and resolves all operational storage paths from server-managed profiles. + +The HTTP listener remains restricted to `127.0.0.1`. Browser writes require a paired device credential and an Origin matching the request's actual loopback Host and port, including `localhost`, custom ports, and SSH-forwarded ports. The service is not intended for LAN or public exposure. + +History search now waits 300ms after typing, searches immediately on Enter, aborts superseded list/detail requests, and allows only the latest response to update the view. No SQLite or full-text History index is introduced. + +The supported runtime floor is Node.js 16.20.2. CI covers 16.20.2 and the current LTS, including dependency installation, Web build, and the full Node test suite. The npm package and repository .NET project versions are 0.5.0. + +Validation covers anonymous-page isolation, device authorization, one-time pairing, credential persistence/reset, dynamic loopback Origin checks, loopback-only binding, server-managed profiles, request supersession, Web build, Node tests, and release-version consistency. + +## Contributors + +Thanks to [@tangquanwei](https://github.com/tangquanwei) for proposing and implementing the Local Web UI, contributing history browsing and the multilingual documentation foundation, and bringing it into v0.5.0 through [PR #80](https://github.com/Dailin521/codex-provider-sync/pull/80). See [PR #73](https://github.com/Dailin521/codex-provider-sync/pull/73) for the original proposal. diff --git a/docs/V0.4_AUTOMATION_PLAN.md b/docs/V0.4_AUTOMATION_PLAN.md index 2298e94..6534cfc 100644 --- a/docs/V0.4_AUTOMATION_PLAN.md +++ b/docs/V0.4_AUTOMATION_PLAN.md @@ -1,6 +1,8 @@ # v0.4.0 Automation execution plan -> Status: authoritative and resumable. +> Status: historical completion record for the v0.4 implementation. It is no +> longer the active execution plan; current behavior is defined by the code, +> user guides, and Automation protocol documentation. > > Integration branch: `agent/v0.4-automation-integration` > diff --git a/docs/WORKING_PRINCIPLE_ZH.md b/docs/WORKING_PRINCIPLE_ZH.md new file mode 100644 index 0000000..3ea7796 --- /dev/null +++ b/docs/WORKING_PRINCIPLE_ZH.md @@ -0,0 +1,749 @@ +# codex-provider-sync 工作原理与落盘机制 + +> 本文是实现与数据边界的技术参考。普通安装和使用请先阅读项目 [README](../README.md)。 + +## 1. 项目定位 + +`codex-provider-sync` 不是用于恢复已经被删除的聊天内容,也不负责登录、认证或切换账号。它是一个 Codex 会话元数据一致性修复工具。 + +当用户切换根级 `model_provider` 后,历史会话的消息内容通常仍保存在磁盘中,但 rollout 文件、SQLite 线程索引或 Desktop 项目路径元数据仍然指向旧 Provider。Codex 在构建会话列表、执行 `/resume` 或按项目筛选会话时,可能因此不再展示这些历史会话。 + +本项目通过同时修复以下数据,使历史会话重新满足当前 Codex 配置的可见性条件: + +- `~/.codex/config.toml` +- `~/.codex/sessions/**/rollout-*.jsonl` +- `~/.codex/archived_sessions/**/rollout-*.jsonl` +- Codex `state_5.sqlite` 中的 `threads` 表 +- `~/.codex/.codex-global-state.json` + +可以把它概括为: + +```text +保留原始会话内容 + + +重新对齐 Provider 和 model 元数据 + + +修复 SQLite 索引与项目路径 + + +通过备份、事务和补偿恢复降低写入风险 +``` + +## 2. 历史会话为什么会不可见 + +一条 Codex 会话的信息并不只存在于一个文件中,而是分散在多个存储层: + +| 数据位置 | 主要用途 | 本项目关注的字段 | +| --- | --- | --- | +| `config.toml` | 当前 Codex Provider 和模型配置 | 根级 `model_provider`、`model`、`sqlite_home` | +| `sessions` | 活跃会话的 rollout 事件历史 | `session_meta.payload.model_provider`、`turn_context.payload.model` | +| `archived_sessions` | 已归档会话的 rollout 事件历史 | 同上 | +| `state_5.sqlite` | 会话列表、索引和筛选依据 | `threads.model_provider`、`model`、`has_user_event`、`cwd` | +| `.codex-global-state.json` | Desktop 的项目和工作区状态 | workspace roots、项目顺序、路径映射 | + +例如当前配置已经切换为: + +```toml +model_provider = "openai" +``` + +但旧 rollout 首行仍然是: + +```json +{"type":"session_meta","payload":{"id":"thread-id","model_provider":"apigather"}} +``` + +同时 SQLite 中仍然保存: + +```text +threads.model_provider = "apigather" +``` + +这时消息正文虽然还在,Codex 的 Provider 过滤、会话索引或项目路径匹配仍可能排除这条记录。项目所做的不是重建消息,而是将这些元数据重新对齐。 + +## 3. 核心数据来源 + +### 3.1 Codex Home + +默认 Codex Home 是: + +```text +~/.codex +``` + +也可以通过以下方式覆盖: + +- CLI 的 `--codex-home` +- 环境变量 `CODEX_HOME` + +### 3.2 当前 Provider + +`sync` 从 `config.toml` 根级读取 `model_provider`。如果根级字段不存在,则使用内置默认值 `openai`。 + +工具只读取第一个 TOML section 之前的根级字段,不会把下面这种 Provider section 内的配置误认为当前根级配置: + +```toml +model_provider = "apigather" +model = "MiniMax-M3" + +[model_providers.apigather] +model = "provider-default-model" +base_url = "https://example.com" +``` + +相关实现位于 [`src/config-file.js`](../src/config-file.js)。 + +### 3.3 SQLite Home 和数据库选择 + +SQLite Home 按以下优先级解析: + +1. CLI 或 GUI 显式覆盖 +2. `config.toml` 根级 `sqlite_home` +3. `CODEX_SQLITE_HOME` +4. `/sqlite` + +只有第 4 种默认布局允许继续检查旧位置: + +```text +/state_5.sqlite +``` + +如果 SQLite Home 来自显式参数、配置或环境变量,而指定位置缺少 `state_5.sqlite`,工具会报错或显示诊断,不会回退到另一个数据库。这样可以避免修改与当前 Codex 实例无关的状态库。 + +默认布局下如果新旧两个数据库都存在,工具会综合比较: + +- 数据库 thread 数量与 rollout 文件数量的距离 +- thread 总数 +- 最新 thread 时间戳 +- 数据库文件修改时间 +- 候选路径优先级 + +然后选择最可能对应当前 Codex 状态的数据库。相关实现位于 [`src/storage-layout.js`](../src/storage-layout.js) 和 [`src/sqlite-state.js`](../src/sqlite-state.js)。 + +### 3.4 Windows 与 WSL SQLite + +Windows 进程不能安全地通过以下 WSL UNC 路径直接操作 SQLite: + +```text +\\wsl.localhost\... +\\wsl$\... +``` + +这些路径在 Windows GUI 和 CLI 中只用于安全诊断。实际 SQLite 操作需要进入对应 WSL 发行版,使用 Linux 路径运行 CLI,例如: + +```bash +codex-provider sync \ + --codex-home /mnt/c/Users/you/.codex \ + --sqlite-home /home/you/.codex/sqlite +``` + +## 4. 一次同步的完整流程 + +一次 `codex-provider sync` 可以概括为: + +```text +读取 config.toml + │ + ├─确定目标 model_provider + ├─读取根级 model + └─解析 SQLite Home + │ + ▼ +扫描 sessions 和 archived_sessions + │ + ├─统计 rollout Provider 分布 + ├─生成需要修改的文件清单 + ├─提取 thread id、cwd 和用户消息标记 + ├─检查 turn_context.model + └─检测 locked 文件和 encrypted_content + │ + ▼ +检查 rollout 文件锁和 SQLite 可写性 + │ + ▼ +创建同步前备份 + │ + ▼ +BEGIN IMMEDIATE + │ + ├─更新 SQLite threads 元数据 + ├─重写可写的 rollout 元数据 + ├─修复 Desktop workspace roots + └─COMMIT + │ + ▼ +清理旧的托管备份,默认保留最近 5 份 +``` + +主编排逻辑位于 [`src/service.js`](../src/service.js)。 + +## 5. Rollout 扫描与修复 + +### 5.1 扫描范围 + +工具递归扫描: + +```text +~/.codex/sessions/**/rollout-*.jsonl +~/.codex/archived_sessions/**/rollout-*.jsonl +``` + +每个 rollout 是一个 JSON Lines 文件。首行通常是 `session_meta`: + +```json +{ + "type": "session_meta", + "payload": { + "id": "thread-id", + "cwd": "D:\\project", + "model_provider": "apigather" + } +} +``` + +工具只有在首行能够解析为合法 `session_meta` 时才处理该文件。 + +### 5.2 扫描时收集的信息 + +工具会从 rollout 中收集: + +- 当前 `model_provider` +- thread ID +- 会话工作目录 `cwd` +- 是否出现用户消息 +- 是否包含 `encrypted_content` +- 所有 `turn_context` 事件中的模型字段 +- 文件大小、mtime、换行格式和首行偏移 + +这些信息用于生成内存中的变更计划,扫描阶段本身不会修改文件。 + +### 5.3 Provider 元数据修复 + +如果 rollout 中的 Provider 与目标 Provider 不一致,工具会把首行: + +```json +"model_provider": "apigather" +``` + +改成: + +```json +"model_provider": "openai" +``` + +只修改 `session_meta` 元数据,不修改后续消息、工具调用、标题或时间戳。 + +### 5.4 model 元数据修复 + +如果当前根级 `model` 存在,工具还会检查并重写所有 `turn_context` 事件中的 `model` 字段,包括嵌套的协作模式模型字段: + +```json +{ + "type": "turn_context", + "payload": { + "model": "MiniMax-M3", + "collaboration_mode": { + "settings": { + "model": "MiniMax-M3" + } + } + } +} +``` + +即使 Provider 已经正确,只要 `turn_context.model` 与目标模型不一致,也会产生一次“仅模型变更”。这是为了让旧会话在 Codex UI 中显示当前有效模型。 + +### 5.5 大文件处理 + +rollout 可能达到数十 MB,单条 `turn_context` 也可能因 `developer_instructions` 等内容超过 64 KB。因此工具采用流式扫描和流式重写,不会为了修改一个字段而把整个文件加载到内存或完整执行 `JSON.parse`/`JSON.stringify`。 + +这样可以降低以下风险: + +- 大文件占用过多内存 +- 重新序列化改变大量无关字节 +- 复杂转义字符串被意外改写 +- 工具输出或 opaque payload 被破坏 + +## 6. Rollout 的落盘策略 + +Rollout 的 Provider 修改有两种落盘方式。 + +### 6.1 等长原地覆盖 + +如果旧 Provider 和新 Provider 的 JSON 编码后 UTF-8 字节长度相同,工具会: + +1. 以读写方式打开原 rollout +2. 再次验证文件大小和 mtime +3. 定位首行 `model_provider` 的字节偏移 +4. 直接覆盖对应字节 +5. 调用文件同步操作 + +这种方式不需要复制大型文件。 + +如果原地覆盖过程中失败,工具会尝试把原字节写回,避免留下部分覆盖结果。 + +### 6.2 临时文件安全重写 + +如果 Provider 字节长度不同,或者不满足原地覆盖条件,工具会: + +1. 创建同目录临时文件 +2. 写入更新后的首行 +3. 保留原始 LF 或 CRLF 分隔符 +4. 从原文件首行之后的位置开始流式复制剩余内容 +5. 再次验证原文件在处理期间没有变化 +6. 将临时文件重命名为原文件 + +### 6.3 `turn_context.model` 重写 + +模型字段通过另一遍逐行流式处理完成。只修改 `type = "turn_context"` 的行,并替换该行内所有符合条件的 `model` 字段。 + +工具会保留: + +- 原有 LF/CRLF +- 原有末尾换行状态 +- 原始文件 mtime +- 非 `turn_context` 行的内容 + +### 6.4 并发变更检测 + +扫描阶段会记录文件大小和 mtime。正式写入前和替换临时文件前都会重新检查。 + +如果 Codex 在此期间追加了新内容,快照会失配,工具会跳过该 rollout,而不是覆盖最新内容。活跃会话锁住文件时,该文件也会被列入 `Skipped locked rollout files`。 + +相关实现位于 [`src/session-files.js`](../src/session-files.js)。 + +## 7. SQLite 修复与落盘 + +### 7.1 写入前检查 + +在备份和修改 rollout 之前,工具先尝试: + +```sql +BEGIN IMMEDIATE; +ROLLBACK; +``` + +这用于验证目标数据库是否可写,以及是否能取得写锁。 + +如果出现 `state_5.sqlite is currently in use`,同步在修改 rollout 之前停止。用户需要关闭 Codex、Codex App 和 app-server,再重新执行相同命令。 + +### 7.2 更新 Provider 和模型 + +实际同步使用 SQLite 事务: + +```sql +BEGIN IMMEDIATE; + +UPDATE threads +SET model_provider = :target_provider +WHERE COALESCE(model_provider, '') <> :target_provider; + +COMMIT; +``` + +如果指定了目标模型,且当前 schema 存在 `model` 列,则相当于: + +```sql +UPDATE threads +SET model_provider = :target_provider, + model = :target_model +WHERE COALESCE(model_provider, '') <> :target_provider + OR COALESCE(model, '') <> :target_model; +``` + +### 7.3 修复 `has_user_event` + +扫描 rollout 时,如果工具确认某个 thread 存在用户消息,而 SQLite 中存在 `has_user_event` 列,则执行等价更新: + +```sql +UPDATE threads +SET has_user_event = 1 +WHERE id = :thread_id + AND COALESCE(has_user_event, 0) <> 1; +``` + +这可以修复由于标记不正确造成的会话可见性问题。 + +### 7.4 修复 `cwd` + +工具以 rollout 首行的 `session_meta.payload.cwd` 为依据,修复对应 SQLite thread 的 `cwd`: + +```sql +UPDATE threads +SET cwd = :rollout_cwd +WHERE id = :thread_id + AND COALESCE(cwd, '') <> :rollout_cwd; +``` + +Windows 扩展路径会规范为 Desktop 更容易匹配的形式,例如: + +```text +\\?\D:\project +``` + +规范为: + +```text +D:\project +``` + +### 7.5 SQLite 真正写入磁盘的位置 + +工具提交事务后,SQLite 自己负责持久化。根据数据库日志模式,修改可能先进入: + +```text +state_5.sqlite-wal +``` + +之后再由 SQLite checkpoint 合并到: + +```text +state_5.sqlite +``` + +因此备份时工具会同时复制主数据库、`-wal` 和 `-shm` 文件(如果存在),不能只复制主数据库。 + +相关实现位于 [`src/sqlite-state.js`](../src/sqlite-state.js) 和 [`src/sqlite.js`](../src/sqlite.js)。 + +## 8. Desktop 项目可见性修复 + +有些会话可以通过 `/resume` 找到,但在 Desktop 的某个项目下不显示。这通常不是 Provider 的唯一问题,还可能是以下路径表示不一致: + +```text +\\?\D:\GitHubProject\demo +``` + +与: + +```text +D:\GitHubProject\demo +``` + +工具读取 SQLite 中 thread 的 `cwd` 分布,并规范化 `.codex-global-state.json` 中的: + +- `electron-saved-workspace-roots` +- `project-order` +- `active-workspace-roots` +- `electron-workspace-root-labels` +- `open-in-target-preferences.perPath` + +更新时会同时写入: + +```text +~/.codex/.codex-global-state.json +~/.codex/.codex-global-state.json.bak +``` + +`status` 还会计算每个项目的会话数量、全局排序 rank、诊断窗口中前 50 条的命中数和路径精确匹配情况。这用于解释 Desktop 可能未展示窗口范围外旧会话的现象;工具不会修改 `updated_at` 将旧会话强行顶到前面。 + +相关实现位于 [`src/workspace-roots.js`](../src/workspace-roots.js)。 + +## 9. 备份的落盘内容 + +每次 `sync` 或 `switch` 在正式修改前都会创建备份: + +```text +/backups_state/provider-sync// +``` + +典型内容如下: + +```text +/ +├── metadata.json +├── config.toml +├── session-meta-backup.json +├── .codex-global-state.json +├── .codex-global-state.json.bak +└── db/ + └── sqlite-home/ + ├── state_5.sqlite + ├── state_5.sqlite-wal + └── state_5.sqlite-shm +``` + +### 9.1 `metadata.json` + +记录: + +- metadata 版本 +- 备份命名空间 +- Codex Home +- SQLite Home +- 目标 Provider +- 创建时间 +- 已复制的 SQLite 文件 +- 计划或实际修改的 rollout 数量 + +### 9.2 `session-meta-backup.json` + +Rollout 通常不会整文件复制。工具只记录恢复所需的元数据: + +- rollout 的绝对路径 +- 原始首行 +- 原始换行分隔符 +- 原始 mtime +- 原始 `turn_context.model` 值及其行位置 +- 是否属于仅模型变更 + +这样做的好处是:恢复时只撤销工具改过的 metadata,不会把同步之后新追加的聊天消息一并删除。 + +### 9.3 自动保留策略 + +默认只保留最近 5 份由本工具创建的托管备份。可以使用: + +```bash +codex-provider sync --keep 10 +codex-provider prune-backups --keep 5 +``` + +备份清理只处理 `backups_state/provider-sync` 下带有正确 metadata 命名空间的目录,不会删除其它备份。 + +相关实现位于 [`src/backup.js`](../src/backup.js)。 + +## 10. 原子性、事务和失败恢复 + +这个工具同时修改 SQLite 和普通文件系统文件。二者无法加入同一个真正的跨存储原子事务,因此项目使用以下组合: + +```text +进程级互斥锁 + + +修改前备份 + + +SQLite 事务 + + +rollout 并发快照检查 + + +失败后的补偿式文件恢复 +``` + +### 10.1 进程级互斥锁 + +执行 `sync`、`switch`、`restore` 或备份清理时,工具会在 Codex Home 的 `tmp` 目录下创建锁目录,并写入: + +- 当前 PID +- 启动时间 +- 操作名称 +- 当前工作目录 + +这可以防止两个本工具实例同时修改同一份 Codex 状态。实现位于 [`src/locking.js`](../src/locking.js)。 + +### 10.2 正常提交路径 + +正常情况下的写入顺序是: + +```text +1. 扫描并生成内存变更计划 +2. 检查 rollout 锁定状态 +3. 验证 SQLite 可写 +4. 将原状态备份落盘 +5. 打开 SQLite BEGIN IMMEDIATE 事务 +6. 更新 SQLite threads +7. 重写可写 rollout +8. 更新 global state +9. COMMIT SQLite +10. 清理旧备份 +``` + +### 10.3 失败路径 + +如果事务打开后的某一步失败: + +1. SQLite 执行 `ROLLBACK` +2. 已经修改的 rollout 根据备份 manifest 恢复 +3. 已经修改的 global state 从备份恢复 +4. `switch` 如果已经修改 `config.toml`,还会恢复原配置 +5. 如果补偿恢复本身失败,错误信息会同时报告原始错误和恢复错误 + +因此它不是一个理论上的全局原子事务,而是“SQLite 原子事务 + 普通文件补偿恢复”。 + +## 11. CLI 操作 + +### 11.1 `status` + +```bash +codex-provider status +``` + +只读检查: + +- 当前 Provider +- rollout Provider 分布 +- SQLite Provider 分布 +- SQLite 实际路径和来源 +- 待修复的 `has_user_event`、`cwd` +- 锁定 rollout +- `encrypted_content` +- 项目会话 rank 和首屏可见性 +- 备份数量和空间占用 + +### 11.2 `sync` + +```bash +codex-provider sync +``` + +使用当前根级 `model_provider` 和 `model` 对齐历史会话。它不修改登录状态,也不管理 `auth.json`。 + +### 11.3 `switch` + +```bash +codex-provider switch apigather +``` + +在 Provider 已存在于 `config.toml` 的前提下: + +1. 计算新的根级 Provider/model 配置 +2. 完成同步前备份 +3. 修改 `config.toml` +4. 执行与 `sync` 相同的元数据修复流程 +5. 失败时恢复原配置 + +模型选择规则: + +- `--model `:显式设置根级 model +- `--keep-root-model`:只切换 Provider,不改根级 model +- 默认:尝试采用目标 Provider section 中的 `model` + +### 11.4 `restore` + +```bash +codex-provider restore +``` + +可以恢复: + +- `config.toml` +- SQLite 数据库及 sidecar 文件 +- rollout metadata +- global state + +也可以通过 `--no-config`、`--no-db` 或 `--no-sessions` 排除部分内容。 + +metadata v2 备份会记录原 SQLite Home。恢复到不同 SQLite Home 默认被拒绝;CLI 必须显式提供目标 SQLite Home、允许 relocation,并禁止同时恢复旧配置,以免旧配置再次指向原数据库。 + +### 11.5 `watch` + +```bash +codex-provider watch +``` + +监听以下变化: + +- `config.toml` +- `state_5.sqlite` +- `state_5.sqlite-wal` +- `state_5.sqlite-shm` + +事件经过默认 750ms 防抖后触发同步。SQLite 临时忙碌会跳过本次并等待后续事件;连续出现非 busy 错误时,watcher 会在达到阈值后退出,避免无限刷错误日志。 + +实现位于 [`src/watch.js`](../src/watch.js)。 + +### 11.6 `web` 与 `prune-backups` + +```bash +codex-provider web +codex-provider prune-backups --keep 5 +``` + +`web` 启动只监听本机回环地址的 Local Web UI,并复用 Node service 的状态、同步、备份和恢复逻辑。`prune-backups` 只清理由本工具管理的旧备份。 + +## 12. 用户入口与核心架构 + +仓库包含三类用户入口: + +- Node.js CLI +- CLI 提供的 Local Web UI +- Windows/macOS 桌面 GUI + +CLI 入口是 [`src/cli.js`](../src/cli.js),Web 服务位于 [`src/web-server.js`](../src/web-server.js),二者的主要业务逻辑位于 [`src/service.js`](../src/service.js)。 + +桌面 GUI 不是简单启动 Node CLI,而是在 `desktop/CodexProviderSync.Core` 中用 C# 实现了相同的核心流程,包括: + +- 配置读取和 Provider 发现 +- SQLite Home 解析 +- rollout 扫描与重写 +- SQLite 事务 +- global state 修复 +- 备份和恢复 +- 锁定文件与 WSL UNC 安全检查 + +C# 主编排逻辑位于 [`desktop/CodexProviderSync.Core/CodexSyncService.cs`](../desktop/CodexProviderSync.Core/CodexSyncService.cs)。 + +## 13. 明确不会修改的内容 + +正常同步不会修改: + +- 用户消息正文 +- assistant 消息正文 +- 工具调用内容 +- 会话标题 +- `updated_at` +- 登录状态 +- `auth.json` +- Provider 凭据 +- 账号认证信息 + +它也不会在不同设备之间复制会话文件,只修复当前 Codex Home 中已有数据的元信息。 + +## 14. 限制与边界情况 + +### 14.1 锁定的 rollout + +活跃会话可能正在持有 rollout 文件。工具会跳过该文件并继续同步其它可写文件及 SQLite,因此结果可能是部分成功。 + +会话结束后再次运行: + +```bash +codex-provider sync +``` + +即可尝试完成剩余重写。 + +### 14.2 锁定的 SQLite + +SQLite 无法取得写锁时,同步会在修改 rollout 之前停止。关闭 Codex、Codex App 和 app-server 后重试。 + +### 14.3 `encrypted_content` + +含有 `encrypted_content` 的历史会话可能与原 Provider、账号或密钥上下文绑定。 + +本工具能够修复列表可见性元数据,但不能重新加密或解密这些内容。跨 Provider/account 后,继续会话或执行 compact 仍可能出现: + +```text +invalid_encrypted_content +``` + +### 14.4 Desktop 首屏限制 + +Codex Desktop 当前可能只加载最近 50 条会话。即使 Provider 和路径都已修复,较旧的会话也可能不在首屏。`status` 会提供 rank 诊断,但工具不会篡改时间戳绕过该限制。 + +## 15. 最终总结 + +这个仓库的核心不是“复制聊天记录”,而是让 Codex 对同一条会话的多个数据视图重新一致: + +```text +config.toml 当前 Provider/model + │ + ▼ +rollout session_meta 与 turn_context + │ + ▼ +SQLite threads 索引 + │ + ▼ +Desktop workspace/project metadata +``` + +落盘层面,它采用: + +```text +先扫描 + → 先验证写锁 + → 先备份 + → SQLite 事务 + → rollout/global state 文件写入 + → 事务提交 + → 失败时补偿恢复 +``` + +因此,它是一套针对 Codex 会话 Provider 可见性问题的、有备份和回滚能力的元数据同步机制。 diff --git a/docs/release-notes/v0.4.1-zh.md b/docs/release-notes/v0.4.1-zh.md new file mode 100644 index 0000000..81c8464 --- /dev/null +++ b/docs/release-notes/v0.4.1-zh.md @@ -0,0 +1,58 @@ + + +> v0.4.1 面向拥有大量历史会话或大型 rollout 文件的用户,显著减少 Windows 同步过程中的重复进程启动、文件扫描和事务日志读取,同时完善备份统计与发布包交付。升级不改变配置、认证或自动化协议。 + +## 🚀 升级后,你可以 + +- **更快地同步大量历史会话**:Windows Node CLI 会复用同一个改写 worker,Core 也减少重复内容扫描和事务日志校验。 +- **更可靠地看到备份占用信息**:备份文件数和大小会被缓存,并在提交、恢复或回滚结束后刷新;刷新失败会明确显示警告。 +- **按使用场景选择下载包**:普通用户继续下载单文件 GUI,自动化用户可单独下载 Automation ZIP,也可下载包含全部工具的 Windows 完整包。 + +## 📦 下载 + +| 使用场景 | 下载 | 更新方式 | +| --- | --- | --- | +| 只需要 Windows GUI | [单文件 GUI](https://github.com/Dailin521/codex-provider-sync/releases/download/v0.4.1/CodexProviderSync.exe) | 支持软件内自动更新 | +| 脚本、CI 或 AI Agent | [Windows x64 自动化接口包](https://github.com/Dailin521/codex-provider-sync/releases/download/v0.4.1/codex-provider-sync-v0.4.1-automation-win-x64.zip) | 手动下载更新 | +| GUI 与自动化接口都需要 | [Windows x64 完整包](https://github.com/Dailin521/codex-provider-sync/releases/download/v0.4.1/codex-provider-sync-v0.4.1-win-x64.zip) | 手动下载更新 | + +[查看全部 SHA-256 校验值](https://github.com/Dailin521/codex-provider-sync/releases/download/v0.4.1/checksums.txt) + +当前 Windows 程序尚未进行代码签名,首次运行可能出现 SmartScreen 的“发布者未知”提示。请确认文件来自本项目 GitHub Release,并按需使用 SHA-256 校验下载内容。 + +## ⬆️ 升级说明 + +1. v0.4.0 Windows GUI 用户可以直接使用内置更新:每天首次启动会自动检查稳定版,也可以点击“检查更新”。 +2. 内置更新只升级 GUI,不会安装 `CodexProviderSync.Automation.exe` 或协议描述文件;自动化用户请手动下载对应 ZIP。 +3. 升级不要求迁移 `config.toml`、SQLite 数据库或已有托管备份。更新后建议先点击“刷新”或运行 `codex-provider status` 核对路径与 Provider。 +4. 同步、切换或恢复前仍建议关闭 Codex Desktop、Codex App 和 app-server,以避免 SQLite 被占用或活跃 rollout 被跳过。 + +## 🛡 安全保障 + +- 工具不负责登录、认证或切换账号,不会修改 `auth.json`,也不会复制凭据到备份或测试夹具。 +- 工具只同步会话可见性相关元数据,不改写对话正文、消息历史、会话标题、`updated_at` 或 `encrypted_content`。 +- 写操作仍在目标修改前创建托管备份,并保留事务记录、失败回滚和崩溃恢复能力。 +- checked apply 仍会在执行前重新验证目标状态;Windows 进程仍不会通过 WSL UNC 路径直接修改 SQLite。 + +## ⚙️ 自动化接口(实验性) + +普通桌面用户可以跳过本节。v0.4.1 没有更改自动化协议版本;`describe`、`status`、`plan`、`sync`、`switch`、`restore` 和 `prune` 的调用方式保持兼容。新的独立 Automation ZIP 包含可执行文件、协议 schema 和中文快速说明。 + +## ⚠️ 重要说明 + +- 如果结果显示 `Skipped locked rollout files`,说明活跃会话在同步期间仍在写入;其余历史会话已继续处理。结束对应会话后再次同步即可完整改写。 +- `\\wsl.localhost\...` 和 `\\wsl$\...` SQLite Home 在 Windows 中仅用于安全诊断;请进入对应 WSL 发行版,用 Linux 路径运行 CLI。 +- 含 `encrypted_content` 的会话跨 Provider 或账号后,通常只能恢复列表可见性;继续对话或 compact 仍可能出现 `invalid_encrypted_content`。 +- Codex Desktop 首屏可能只显示最近 50 条会话;本工具不会修改 `updated_at` 来改变排序。 + +## 🔍 验证结果 + +- PR #75 的 Node CLI 验证通过 207 项测试;100 个 rollout 文件的本地基准仅使用一个 Windows worker,约 1.1 秒完成。 +- v0.4.1 发布准备验证通过 385 项 .NET 测试(1 项按环境跳过)和 210 项 Node 测试,Release 构建为 0 警告、0 错误。 +- GitHub Actions `ci` 已在合并提交前通过;Release 工作流还会再次校验版本一致性、完整测试、Windows 发布构建、打包内容和 SHA-256 文件。 + +[查看 v0.4.0 到 v0.4.1 的完整代码变更](https://github.com/Dailin521/codex-provider-sync/compare/v0.4.0...v0.4.1)。 + +## 🙏 贡献者 + +感谢本轮审查与测试反馈帮助完善活跃 rollout 跳过策略、备份统计刷新、跨平台测试和恢复警告展示。 diff --git a/docs/release-notes/v0.5.0-zh.md b/docs/release-notes/v0.5.0-zh.md new file mode 100644 index 0000000..6982e30 --- /dev/null +++ b/docs/release-notes/v0.5.0-zh.md @@ -0,0 +1,42 @@ + + +> v0.5.0 集中修复 Web UI 的本地认证、路径边界和 History 请求竞态:浏览器零输入自动配对,服务端只保存设备凭证哈希,所有写路径通过命名配置解析。 + +## 📦 下载 + +v0.5.0 是 CLI/Web 的 npm 发布: + +```bash +npm install -g @dailin521/codex-provider-sync@0.5.0 +``` + +Windows GUI 使用独立的 GitHub Release 发布线;本次没有创建 v0.5.0 Windows GUI Release,GitHub Releases 中的最新 GUI 版本可能不同。如另行下载 Windows GUI,请只使用项目 Release 并核对 SHA-256;未签名可执行文件仍可能触发 SmartScreen。 + +## ⬆️ 升级说明 + +Node CLI 的最低版本现在是 Node.js 16.20.2。升级不需要迁移 Codex rollout、SQLite 或备份;首次运行 `codex-provider web` 会自动打开一次性配对链接。需要撤销旧浏览器授权时运行 `codex-provider web --reset-access`。 + +## 🛡 安全保障 + +- 首页不再包含 API token,一次性配对材料只出现在 URL fragment,不进入查询参数、HTML 或服务活动日志。 +- 服务只监听 `127.0.0.1`,浏览器写请求同时校验设备凭证和实际回环 Origin。 +- 浏览器保存设备凭证,服务端只持久化 SHA-256 哈希;存储路径由服务端 profile 管理。 +- 工具仍不读取或修改 `auth.json`,写入前的备份、事务和回滚机制保持不变。 + +## ⚠️ 重要说明 + +- 不要把 Web UI 直接暴露到公网或局域网;远程使用请通过 SSH 回环隧道。 +- 工具不会修改线程 `updated_at`,也不会改变历史排序。 +- 跨账号或 Provider 的 `encrypted_content` 仍可能无法继续解密或对话。 +- Windows 下的 WSL UNC SQLite Home 仍是诊断边界;写操作应在 WSL 内使用 Linux 路径执行。 + +## 🔍 验证结果 + +- Node.js 16.20.2 和当前 LTS:依赖安装、Web 构建和完整 Node 测试。 +- 认证:匿名首页、无设备凭证、一次性凭证重用、刷新/重启保持授权、reset 失效。 +- 网络:非法 Origin、localhost/自定义端口/SSH 转发 Host、只监听回环地址。 +- 行为:History 300ms 防抖、取消旧请求、只接收最新响应,以及 0.5.0 版本一致性。 + +## 🙏 贡献者 + +感谢 [@tangquanwei](https://github.com/tangquanwei) 提出并实现本地 Web UI,贡献聊天记录浏览和多语言文档基础,并通过 [PR #80](https://github.com/Dailin521/codex-provider-sync/pull/80) 将其带入 v0.5.0;最初提案见 [PR #73](https://github.com/Dailin521/codex-provider-sync/pull/73)。 diff --git a/images/README/2026-08-05T03-53-48.708Z.png b/images/README/2026-08-05T03-53-48.708Z.png new file mode 100644 index 0000000..511cf82 Binary files /dev/null and b/images/README/2026-08-05T03-53-48.708Z.png differ diff --git a/images/README/provider-metadata-sync-flow.png b/images/README/provider-metadata-sync-flow.png new file mode 100644 index 0000000..9ad3ba5 Binary files /dev/null and b/images/README/provider-metadata-sync-flow.png differ diff --git a/package-lock.json b/package-lock.json index 17a4f82..bbdc827 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,23 +1,822 @@ { - "name": "codex-provider-sync", - "version": "0.4.0", + "name": "@dailin521/codex-provider-sync", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "codex-provider-sync", - "version": "0.4.0", + "name": "@dailin521/codex-provider-sync", + "version": "0.5.0", "license": "MIT", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, "bin": { "codex-provider": "src/cli.js" }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.4", + "vite": "^4.5.14" + }, "engines": { - "node": ">=16.0.0" + "node": ">=16.20.2" }, "optionalDependencies": { "better-sqlite3": "8.7.0" } }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmmirror.com/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmmirror.com/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.3.4", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-4.3.4.tgz", + "integrity": "sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.26.0", + "@babel/plugin-transform-react-jsx-self": "^7.25.9", + "@babel/plugin-transform-react-jsx-source": "^7.25.9", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.14.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -38,6 +837,19 @@ ], "optional": true }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.12", + "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz", + "integrity": "sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/better-sqlite3": { "version": "8.7.0", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-8.7.0.tgz", @@ -69,6 +881,47 @@ "readable-stream": "^3.4.0" } }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/browserslist/node_modules/node-releases": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", + "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", + "dev": true, + "license": "MIT" + }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -93,12 +946,58 @@ "ieee754": "^1.1.13" } }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, "node_modules/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "optional": true }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -132,6 +1031,13 @@ "node": ">=8" } }, + "node_modules/electron-to-chromium": { + "version": "1.5.400", + "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.400.tgz", + "integrity": "sha512-96EWDNjM59SYflgeV5Ylsf4EMiq1a25YjCnJH7cxn/AF2H3pILRweaUnoLax0yKHWdpOzY6JKEu45e8irqZIHA==", + "dev": true, + "license": "ISC" + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -141,6 +1047,54 @@ "once": "^1.4.0" } }, + "node_modules/esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -162,6 +1116,31 @@ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "optional": true }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -200,6 +1179,60 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "optional": true }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, "node_modules/mimic-response": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", @@ -227,6 +1260,32 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "optional": true }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.17", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/napi-build-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", @@ -254,6 +1313,42 @@ "wrappy": "1" } }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -306,6 +1401,41 @@ "rc": "cli.js" } }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmmirror.com/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -320,6 +1450,23 @@ "node": ">= 6" } }, + "node_modules/rollup": { + "version": "3.30.0", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-3.30.0.tgz", + "integrity": "sha512-kQvGasUgN+AlWGliFn2POSajRQEsULVYFGTvOZmK06d7vCD+YhZztt70kGk3qaeAXeWYL5eO7zx+rAubBc55eA==", + "dev": true, + "license": "MIT", + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=14.18.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -340,6 +1487,15 @@ ], "optional": true }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/semver": { "version": "7.8.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", @@ -397,6 +1553,16 @@ "simple-concat": "^1.0.0" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -455,17 +1621,111 @@ "node": "*" } }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "optional": true }, + "node_modules/vite": { + "version": "4.5.14", + "resolved": "https://registry.npmmirror.com/vite/-/vite-4.5.14.tgz", + "integrity": "sha512-+v57oAaoYNnO3hIu5Z/tJRZjq5aHM2zDve9YZ8HngVHbhk66RStobhb1sqPMIPEleV6cNKYK4eGrAbE9Ulbl2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.18.10", + "postcss": "^8.4.27", + "rollup": "^3.27.1" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@types/node": ">= 14", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "optional": true + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" } } } diff --git a/package.json b/package.json index 6b3596a..476adfd 100644 --- a/package.json +++ b/package.json @@ -1,23 +1,30 @@ { - "name": "codex-provider-sync", - "version": "0.4.0", + "name": "@dailin521/codex-provider-sync", + "version": "0.5.0", "description": "Synchronize Codex session provider metadata across rollout files and SQLite state.", "type": "module", "files": [ "README.md", "CHANGELOG.md", + "CONTRIBUTING.md", + "CONTRIBUTORS.md", "AGENTS.md", "docs", - "src" + "images/README", + "src", + "web/dist" ], "bin": { - "codex-provider": "./src/cli.js" + "codex-provider": "src/cli.js" }, "scripts": { - "test": "node --test" + "test": "node --test", + "web:build": "vite build --config web/vite.config.js", + "web:start": "node src/cli.js web", + "publish:npm": "node scripts/publish-npm.js" }, "engines": { - "node": ">=16.0.0" + "node": ">=16.20.2" }, "keywords": [ "codex", @@ -25,8 +32,31 @@ "sessions", "provider" ], + "author": "Dailin521", "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/Dailin521/codex-provider-sync.git" + }, + "homepage": "https://github.com/Dailin521/codex-provider-sync#readme", + "bugs": { + "url": "https://github.com/Dailin521/codex-provider-sync/issues" + }, + "publishConfig": { + "access": "public" + }, "optionalDependencies": { "better-sqlite3": "8.7.0" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.4", + "vite": "^4.5.14" + }, + "overrides": { + "node-releases": "2.0.18" } } diff --git a/scripts/publish-npm.js b/scripts/publish-npm.js new file mode 100755 index 0000000..6fdf9b0 --- /dev/null +++ b/scripts/publish-npm.js @@ -0,0 +1,107 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function usage() { + console.log(`Usage: npm run publish:npm -- [options] + +Options: + --dry-run Build, test, and preview the package without publishing. + --skip-tests Skip npm test (the Web UI build still runs). + --otp CODE Pass a one-time npm 2FA code without storing it. + --tag TAG Publish with an npm dist-tag (default: latest). + --registry URL npm registry (default: https://registry.npmjs.org/). +`); +} + +function parseArgs(argv) { + const options = { dryRun: false, skipTests: false, otp: process.env.NPM_OTP ?? "", tag: "latest", registry: "https://registry.npmjs.org/" }; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--help" || argument === "-h") { usage(); process.exit(0); } + if (argument === "--dry-run") { options.dryRun = true; continue; } + if (argument === "--skip-tests") { options.skipTests = true; continue; } + if (argument === "--otp" || argument === "--tag" || argument === "--registry") { + const value = argv[++index]; + if (!value) throw new Error(`${argument} requires a value.`); + if (argument === "--otp") options.otp = value; + if (argument === "--tag") options.tag = value; + if (argument === "--registry") options.registry = value; + continue; + } + throw new Error(`Unknown option: ${argument}`); + } + return options; +} + +function npmInvocation(args) { + const candidates = [ + process.env.npm_execpath?.trim(), + path.resolve(path.dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js"), + path.resolve(path.dirname(process.execPath), "..", "lib", "node_modules", "npm", "bin", "npm-cli.js"), + ...(process.env.PATH ?? "") + .split(path.delimiter) + .filter(Boolean) + .map((directory) => path.resolve(directory.replace(/^"|"$/g, ""), "node_modules", "npm", "bin", "npm-cli.js")), + ]; + const npmCli = candidates.find((candidate) => candidate && fs.existsSync(candidate)); + if (npmCli) { + return { command: process.execPath, args: [npmCli, ...args] }; + } + + if (process.platform === "win32") { + throw new Error("Could not locate npm-cli.js. Run this script through `npm run publish:npm` or repair the Node.js/npm installation."); + } + return { command: "npm", args }; +} + +function runNpm(args, { env = process.env } = {}) { + console.log(`\n$ npm ${args.map((value) => value === env.NPM_OTP ? "--otp ******" : value).join(" ")}`); + const invocation = npmInvocation(args); + const result = spawnSync(invocation.command, invocation.args, { cwd: rootDir, env, stdio: "inherit" }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`npm ${args[0]} failed with exit code ${result.status}.`); +} + +function packageInfo() { + const packagePath = path.join(rootDir, "package.json"); + const manifest = JSON.parse(fs.readFileSync(packagePath, "utf8")); + if (!manifest.name || !manifest.version) throw new Error("package.json must define name and version."); + if (!manifest.bin?.["codex-provider"]) throw new Error("package.json must expose the codex-provider bin entry."); + const binPath = path.resolve(rootDir, manifest.bin["codex-provider"]); + if (!fs.existsSync(binPath)) throw new Error(`npm bin entry does not exist: ${binPath}`); + return manifest; +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + const manifest = packageInfo(); + console.log(`Preparing ${manifest.name}@${manifest.version} for npm.`); + + const registryArgs = ["--registry", options.registry]; + if (!options.dryRun) runNpm(["whoami", ...registryArgs]); + runNpm(["run", "web:build"]); + if (!options.skipTests) runNpm(["test"]); + runNpm(["pack", "--dry-run", "--json", ...registryArgs]); + + const publishArgs = ["publish", "--access", "public", "--tag", options.tag, ...registryArgs]; + if (options.otp) publishArgs.push("--otp", options.otp); + if (options.dryRun) { + console.log("npm dry-run completed; nothing was published."); + return; + } + runNpm(publishArgs, { env: { ...process.env, ...(options.otp ? { NPM_OTP: options.otp } : {}) } }); + console.log(`${manifest.name}@${manifest.version} published successfully.`); +} + +try { + main(); +} catch (error) { + console.error(`\nPublish failed: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; +} diff --git a/src/backup.js b/src/backup.js index 8d0ca4c..fbbc20e 100644 --- a/src/backup.js +++ b/src/backup.js @@ -322,26 +322,18 @@ export async function createBackup({ "utf8" ); - await writeFileAtomic( - path.join(backupDir, "metadata.json"), - JSON.stringify( - { - version: 2, - namespace: BACKUP_NAMESPACE, - codexHome, - sqliteHome: actualSqliteHome, - targetProvider, - createdAt: sessionManifest.createdAt, - dbFiles: copiedDbFiles, - sqliteDbFiles: copiedSqliteDbFiles, - globalStateFiles, - changedSessionFiles: sessionChanges.length - }, - null, - 2 - ), - "utf8" - ); + await writeMetadataWithInventory(backupDir, { + version: 2, + namespace: BACKUP_NAMESPACE, + codexHome, + sqliteHome: actualSqliteHome, + targetProvider, + createdAt: sessionManifest.createdAt, + dbFiles: copiedDbFiles, + sqliteDbFiles: copiedSqliteDbFiles, + globalStateFiles, + changedSessionFiles: sessionChanges.length + }); return backupDir; } @@ -382,12 +374,19 @@ export async function updateSessionBackupManifest(backupDir, sessionChanges, opt "utf8", { faultInjector: options.faultInjector } ); - await writeFileAtomic( - metadataPath, - JSON.stringify(metadata, null, 2), - "utf8", - { faultInjector: options.faultInjector } - ); + await writeMetadataWithInventory(backupDir, metadata, { + faultInjector: options.faultInjector + }); +} + +export async function refreshBackupInventory(backupDir, options = {}) { + const normalizedBackupDir = path.resolve(backupDir); + const metadataPath = path.join(normalizedBackupDir, "metadata.json"); + const metadata = JSON.parse(await fs.readFile(metadataPath, "utf8")); + if (metadata?.namespace !== BACKUP_NAMESPACE || !new Set([1, 2]).has(metadata.version)) { + throw new Error(`Unsupported backup metadata in ${metadataPath}.`); + } + await writeMetadataWithInventory(normalizedBackupDir, metadata, options); } export async function getBackupSummary(codexHome) { @@ -395,7 +394,7 @@ export async function getBackupSummary(codexHome) { const backupDirs = await listManagedBackupDirectories(backupRoot); let totalBytes = 0; for (const entry of backupDirs) { - totalBytes += await getDirectorySize(entry.fullPath); + totalBytes += await getBackupDirectorySize(entry.fullPath); } return { @@ -404,6 +403,25 @@ export async function getBackupSummary(codexHome) { }; } +export async function listBackups(codexHome) { + const backupRoot = defaultBackupRoot(codexHome); + const backupDirs = await listManagedBackupDirectories(backupRoot); + const backups = []; + + for (const entry of backupDirs) { + const metadataPath = path.join(entry.fullPath, "metadata.json"); + const metadata = JSON.parse(await fs.readFile(metadataPath, "utf8")); + backups.push({ + id: entry.name, + path: entry.fullPath, + sizeBytes: await getDirectorySize(entry.fullPath), + metadata + }); + } + + return { backupRoot, backups }; +} + export async function pruneBackups(codexHome, keepCount = DEFAULT_BACKUP_RETENTION_COUNT) { if (!Number.isInteger(keepCount) || keepCount < 0) { throw new Error(`Invalid keep count: ${keepCount}. Expected a non-negative integer.`); @@ -420,7 +438,7 @@ export async function pruneBackups(codexHome, keepCount = DEFAULT_BACKUP_RETENTI .filter((entry) => !protectedBackups.has(pathComparisonKey(entry.fullPath))); let freedBytes = 0; for (const entry of toDelete) { - freedBytes += await getDirectorySize(entry.fullPath); + freedBytes += await getBackupDirectorySize(entry.fullPath); await fs.rm(entry.fullPath, { recursive: true, force: true }); } @@ -715,29 +733,77 @@ async function isManagedBackupDirectory(backupDir) { } } +async function writeMetadataWithInventory(backupDir, metadata, options = {}) { + const metadataPath = path.join(backupDir, "metadata.json"); + const payload = await getDirectoryInventory(backupDir, metadataPath); + const fileCount = payload.fileCount + 1; + let sizeBytes = 0; + let serialized = ""; + for (let attempt = 0; attempt < 8; attempt += 1) { + serialized = JSON.stringify({ ...metadata, sizeBytes, fileCount }, null, 2); + const nextSizeBytes = payload.sizeBytes + Buffer.byteLength(serialized, "utf8"); + if (nextSizeBytes === sizeBytes) { + await writeFileAtomic(metadataPath, serialized, "utf8", options); + return; + } + sizeBytes = nextSizeBytes; + } + throw new Error(`Backup metadata inventory did not converge: ${metadataPath}`); +} + +async function getBackupDirectorySize(backupDir) { + try { + const metadata = JSON.parse( + await fs.readFile(path.join(backupDir, "metadata.json"), "utf8") + ); + if (metadata?.namespace === BACKUP_NAMESPACE + && Number.isSafeInteger(metadata.sizeBytes) + && metadata.sizeBytes >= 0 + && Number.isSafeInteger(metadata.fileCount) + && metadata.fileCount >= 1) { + return metadata.sizeBytes; + } + } catch { + // Older or damaged inventory fields fall back to the recursive scan below. + } + return getDirectorySize(backupDir); +} + async function getDirectorySize(directoryPath) { + return (await getDirectoryInventory(directoryPath)).sizeBytes; +} + +async function getDirectoryInventory(directoryPath, excludedFilePath = null) { let entries; try { entries = await fs.readdir(directoryPath, { withFileTypes: true }); } catch (error) { if (error?.code === "ENOENT") { - return 0; + return { sizeBytes: 0, fileCount: 0 }; } throw error; } - let total = 0; + let sizeBytes = 0; + let fileCount = 0; + const excluded = excludedFilePath === null ? null : path.resolve(excludedFilePath); for (const entry of entries) { const fullPath = path.join(directoryPath, entry.name); if (entry.isDirectory()) { - total += await getDirectorySize(fullPath); + const child = await getDirectoryInventory(fullPath, excluded); + sizeBytes += child.sizeBytes; + fileCount += child.fileCount; continue; } if (entry.isFile()) { + if (excluded !== null && path.resolve(fullPath) === excluded) { + continue; + } const stat = await fs.stat(fullPath); - total += stat.size; + sizeBytes += stat.size; + fileCount += 1; } } - return total; + return { sizeBytes, fileCount }; } diff --git a/src/cli.js b/src/cli.js old mode 100644 new mode 100755 index 836253a..271b28d --- a/src/cli.js +++ b/src/cli.js @@ -19,6 +19,7 @@ Usage: codex-provider sync [--provider ID] [--keep N] [--codex-home PATH] [--sqlite-home PATH] codex-provider switch [--model NAME] [--keep-root-model] [--keep N] [--codex-home PATH] [--sqlite-home PATH] codex-provider watch [--codex-home PATH] [--sqlite-home PATH] [--debounce-ms N] [--once] [--no-state-db] + codex-provider web [--port N] [--no-open] [--reset-access] [--codex-home PATH] [--sqlite-home PATH] codex-provider prune-backups [--keep N] [--codex-home PATH] codex-provider restore [--no-config] [--no-db] [--no-sessions] [--allow-sqlite-home-relocation] [--codex-home PATH] [--sqlite-home PATH] codex-provider install-windows-launcher [--dir PATH] [--codex-home PATH] [--sqlite-home PATH] @@ -33,6 +34,13 @@ watch flags: --debounce-ms N wait N milliseconds after a change before syncing (default 750) --once exit after the first successful sync --no-state-db only watch config.toml, ignore SQLite state events + +web flags: + --port N bind the local Web UI to 127.0.0.1:N (default 8791) + --no-open do not open the system browser automatically + --reset-access invalidate all paired browsers before creating a new pairing + --codex-home PATH set the default server-managed storage profile + --sqlite-home PATH set the default profile SQLite Home override `); } @@ -196,6 +204,8 @@ async function main() { return; } + assertSupportedNodeVersion(); + if (command === "status") { const { getStatus, renderStatus } = await loadService(); const status = await getStatus({ @@ -313,6 +323,41 @@ async function main() { return; } + if (command === "web") { + const { startWebUi } = await import("./web-server.js"); + const port = flags.port === undefined ? 8791 : parseKeepCount(flags.port, { allowZero: true }); + const handle = await startWebUi({ + port, + openBrowser: !flags["no-open"], + resetAccess: Boolean(flags["reset-access"]), + codexHome: flags["codex-home"], + sqliteHome: flags["sqlite-home"] + }); + console.log(`Codex Provider Sync Web UI: ${handle.url}`); + if (flags["no-open"] || !handle.browserOpened) { + console.log(`One-time pairing link: ${handle.pairingUrl}`); + } + if (handle.reused) { + console.log("Opened the existing Codex Provider Sync Web UI instance."); + return; + } + console.log("The server only listens on 127.0.0.1. Press Ctrl+C to stop it."); + await new Promise((resolve) => { + let closing = false; + const close = async () => { + if (closing) { + return; + } + closing = true; + await handle.close().catch(() => {}); + resolve(); + }; + process.once("SIGINT", close); + process.once("SIGTERM", close); + }); + return; + } + if (command === "restore") { const { runRestore } = await loadService(); const backupDir = positionals[1] ?? flags.backup; @@ -328,6 +373,9 @@ async function main() { console.log(`Restored backup from ${path.resolve(backupDir)}`); console.log(`Codex home: ${result.codexHome}`); console.log(`Provider at backup time: ${result.targetProvider}`); + if (result.backupInventoryWarning) { + console.log(`Backup inventory warning: ${result.backupInventoryWarning}`); + } return; } diff --git a/src/history.js b/src/history.js new file mode 100644 index 0000000..065932a --- /dev/null +++ b/src/history.js @@ -0,0 +1,223 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import fsSync from "node:fs"; +import path from "node:path"; +import readline from "node:readline"; + +import { SESSION_DIRS } from "./constants.js"; + +const DEFAULT_PAGE_SIZE = 50; +const MAX_PAGE_SIZE = 100; +const DEFAULT_MESSAGE_LIMIT = 200; + +function normalizedRolloutPath(rolloutPath) { + const absolutePath = path.resolve(rolloutPath); + return process.platform === "win32" ? absolutePath.toLowerCase() : absolutePath; +} + +function fallbackSessionId(rolloutPath) { + const digest = crypto.createHash("sha256") + .update(normalizedRolloutPath(rolloutPath), "utf8") + .digest("base64url"); + return `rollout:${digest}`; +} + +function normalizeText(value) { + if (typeof value !== "string") return ""; + return value.replace(/\r\n/g, "\n").trim(); +} + +function firstText(...values) { + for (const value of values) { + const text = normalizeText(value); + if (text) return text; + } + return ""; +} + +function contentText(value) { + if (typeof value === "string") return normalizeText(value); + if (!Array.isArray(value)) return ""; + return value + .filter((item) => item && typeof item === "object" && (item.type === "output_text" || item.type === "text" || item.type === "input_text")) + .map((item) => normalizeText(item.text)) + .filter(Boolean) + .join("\n"); +} + +function messageFromRecord(record) { + if (!record || typeof record !== "object") return null; + const timestamp = record.timestamp ?? record.payload?.timestamp ?? null; + const eventType = record.payload?.type; + if (record.type === "event_msg" && (eventType === "user_message" || eventType === "assistant_message")) { + const role = eventType === "user_message" ? "user" : "assistant"; + const text = firstText(record.payload?.message, record.payload?.text); + return text ? { role, text, timestamp, canonicalUser: role === "user" } : null; + } + + for (const key of ["payload", "item", "msg"]) { + const value = record[key]; + if (!value || typeof value !== "object" || !["user", "assistant"].includes(value.role)) continue; + const text = firstText(contentText(value.content), value.message, value.text); + return text ? { role: value.role, text, timestamp, canonicalUser: false } : null; + } + if (record.type === "user_message" || record.type === "assistant_message") { + const text = firstText(record.message, record.text, record.payload?.message, record.payload?.text); + const role = record.type === "user_message" ? "user" : "assistant"; + return text ? { role, text, timestamp, canonicalUser: role === "user" } : null; + } + return null; +} + +async function listRolloutFiles(root) { + const result = []; + async function walk(directory) { + let entries; + try { + entries = await fs.readdir(directory, { withFileTypes: true }); + } catch (error) { + if (error?.code === "ENOENT") return; + throw error; + } + for (const entry of entries) { + const fullPath = path.join(directory, entry.name); + if (entry.isDirectory()) await walk(fullPath); + else if (entry.isFile() && entry.name.startsWith("rollout-") && entry.name.endsWith(".jsonl")) result.push(fullPath); + } + } + await walk(root); + return result; +} + +async function readRollout(filePath, archived) { + const stat = await fs.stat(filePath); + const stream = fsSync.createReadStream(filePath, { encoding: "utf8" }); + const lines = readline.createInterface({ input: stream, crlfDelay: Infinity }); + let meta = null; + const messages = []; + let sequence = 0; + try { + for await (const line of lines) { + if (!line.trim()) continue; + let record; + try { record = JSON.parse(line); } catch { continue; } + if (!meta && record.type === "session_meta" && record.payload && typeof record.payload === "object") { + const payload = record.payload; + meta = { + threadId: typeof payload.id === "string" && payload.id ? payload.id : null, + title: firstText(payload.title, payload.name), + cwd: firstText(payload.cwd), + provider: firstText(payload.model_provider) || "(missing)", + model: firstText(payload.model), + createdAt: record.timestamp ?? payload.timestamp ?? null + }; + } + const message = messageFromRecord(record); + if (message) messages.push({ ...message, sequence: ++sequence }); + } + } finally { + lines.close(); + stream.destroy(); + } + if (!meta) return null; + const hasCanonicalUserMessages = messages.some((message) => message.canonicalUser); + const visibleMessages = messages + .filter((message) => message.role !== "user" || !hasCanonicalUserMessages || message.canonicalUser) + .map(({ canonicalUser: _canonicalUser, sequence: _sequence, ...message }, index) => ({ ...message, sequence: index + 1 })); + const rolloutPath = path.resolve(filePath); + const updatedAt = visibleMessages.at(-1)?.timestamp ?? stat.mtime.toISOString(); + return { + ...meta, + id: meta.threadId ?? fallbackSessionId(rolloutPath), + rolloutPath, + updatedAt, + archived, + messages: visibleMessages, + messageCount: visibleMessages.length, + filePath, + mtimeMs: stat.mtimeMs + }; +} + +async function collectHistory(codexHome) { + const sessions = []; + for (const dirName of SESSION_DIRS) { + const files = await listRolloutFiles(path.join(codexHome, dirName)); + for (const filePath of files) { + let session; + try { + session = await readRollout(filePath, dirName === "archived_sessions"); + } catch (error) { + if (error?.code === "ENOENT") continue; + throw error; + } + if (session) sessions.push(session); + } + } + const byId = new Map(); + for (const session of sessions) { + const key = session.threadId + ? `thread:${session.threadId}` + : `path:${normalizedRolloutPath(session.rolloutPath)}`; + const existing = byId.get(key); + if (!existing || session.mtimeMs >= existing.mtimeMs) byId.set(key, session); + } + return [...byId.values()].sort((a, b) => Date.parse(b.updatedAt || 0) - Date.parse(a.updatedAt || 0) || b.mtimeMs - a.mtimeMs); +} + +function publicSession(session) { + const firstUserMessage = session.messages.find((message) => message.role === "user")?.text ?? ""; + return { + id: session.id, + rolloutPath: session.rolloutPath, + title: session.title || firstUserMessage.slice(0, 80) || "未命名会话", + cwd: session.cwd, + provider: session.provider, + model: session.model, + archived: session.archived, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + messageCount: session.messageCount, + firstUserMessage: firstUserMessage.slice(0, 240) + }; +} + +export function validateHistoryPage(pageValue, pageSizeValue = DEFAULT_PAGE_SIZE) { + const page = pageValue === undefined ? 1 : Number(pageValue); + const pageSize = pageSizeValue === undefined ? DEFAULT_PAGE_SIZE : Number(pageSizeValue); + if (!Number.isInteger(page) || page < 1) throw new Error("page must be a positive integer."); + if (!Number.isInteger(pageSize) || pageSize < 10 || pageSize > MAX_PAGE_SIZE) throw new Error(`pageSize must be an integer between 10 and ${MAX_PAGE_SIZE}.`); + return { page, pageSize }; +} + +export async function listHistory(codexHome, options = {}) { + const { page, pageSize } = validateHistoryPage(options.page, options.pageSize ?? DEFAULT_PAGE_SIZE); + const query = normalizeText(options.query).toLowerCase(); + const project = normalizeText(options.project).toLowerCase(); + const provider = normalizeText(options.provider); + const archived = options.archived ?? "all"; + if (!["all", "active", "archived"].includes(archived)) throw new Error("archived must be all, active, or archived."); + const sessions = await collectHistory(codexHome); + const filtered = sessions.filter((session) => { + if (provider && session.provider !== provider) return false; + if (archived !== "all" && session.archived !== (archived === "archived")) return false; + if (project && !session.cwd.toLowerCase().includes(project)) return false; + if (query) { + const haystack = [session.title, session.cwd, session.provider, session.messages[0]?.text, ...session.messages.map((message) => message.text)].join("\n").toLowerCase(); + if (!haystack.includes(query)) return false; + } + return true; + }); + const start = (page - 1) * pageSize; + return { page, pageSize, total: filtered.length, hasNextPage: start + pageSize < filtered.length, sessions: filtered.slice(start, start + pageSize).map(publicSession) }; +} + +export async function getHistorySession(codexHome, sessionId, { messageLimit = DEFAULT_MESSAGE_LIMIT } = {}) { + if (typeof sessionId !== "string" || !sessionId.trim()) throw new Error("sessionId is required."); + const sessions = await collectHistory(codexHome); + const session = sessions.find((item) => item.id === sessionId); + if (!session) throw new Error("The selected session was not found in this Codex Home."); + const safeLimit = Number.isInteger(messageLimit) && messageLimit > 0 ? Math.min(messageLimit, DEFAULT_MESSAGE_LIMIT) : DEFAULT_MESSAGE_LIMIT; + const messages = session.messages.slice(-safeLimit); + return { session: publicSession(session), messages, truncated: messages.length < session.messages.length, returnedMessageCount: messages.length }; +} diff --git a/src/node-version.js b/src/node-version.js index e317efd..05c18de 100644 --- a/src/node-version.js +++ b/src/node-version.js @@ -1,13 +1,27 @@ -export const MINIMUM_NODE_MAJOR_VERSION = 16; +export const MINIMUM_NODE_VERSION = "16.20.2"; + +function parseVersion(value) { + const match = String(value).replace(/^v/, "").match(/^(\d+)\.(\d+)\.(\d+)/); + return match ? match.slice(1).map((part) => Number.parseInt(part, 10)) : null; +} + +function versionAtLeast(current, minimum) { + for (let index = 0; index < minimum.length; index += 1) { + if (current[index] > minimum[index]) return true; + if (current[index] < minimum[index]) return false; + } + return true; +} export function getUnsupportedNodeVersionMessage(nodeVersion = process.versions.node) { - const majorVersion = Number.parseInt(String(nodeVersion).split(".")[0] ?? "", 10); - if (Number.isInteger(majorVersion) && majorVersion >= MINIMUM_NODE_MAJOR_VERSION) { + const current = parseVersion(nodeVersion); + const minimum = parseVersion(MINIMUM_NODE_VERSION); + if (current && minimum && versionAtLeast(current, minimum)) { return null; } const displayVersion = String(nodeVersion).startsWith("v") ? String(nodeVersion) : `v${nodeVersion}`; - return `codex-provider-sync requires Node.js ${MINIMUM_NODE_MAJOR_VERSION}+. ` + return `codex-provider-sync requires Node.js ${MINIMUM_NODE_VERSION} or newer. ` + `Current Node.js version: ${displayVersion}. ` + "Please upgrade Node.js, then reinstall or rerun codex-provider."; } diff --git a/src/service.js b/src/service.js index 2ceed03..d524be5 100644 --- a/src/service.js +++ b/src/service.js @@ -22,9 +22,9 @@ import { getBackupRecoveryCoverage, getBackupSummary, pruneBackups, + refreshBackupInventory, restoreBackup, - restoreGlobalStateFilesFromBackup, - updateSessionBackupManifest + restoreGlobalStateFilesFromBackup } from "./backup.js"; import { acquireLock } from "./locking.js"; import { @@ -201,6 +201,20 @@ async function commitJournalWithReconciliation(journal, faultInjector) { throw new Error(`Transaction journal did not persist a valid committed terminal state: ${journal.filePath}`); } +// Rewrites the retained backup's recorded size and file count after the journal +// reached a terminal state, so status and pruning do not trust an inventory +// captured before those journal records existed. Used on the rollback paths, +// where the caller is already reporting a failure: a bookkeeping problem here +// must never replace the original error. +async function tryRefreshBackupInventory(backupDir) { + try { + await refreshBackupInventory(backupDir); + } catch { + // The original sync failure and its rollback details are the authoritative + // diagnosis and must reach the caller unchanged. + } +} + async function rollbackJournalWithReconciliation(journal, faultInjector) { let acknowledgementError = null; try { @@ -254,11 +268,17 @@ function buildEncryptedContentWarning(encryptedContentCounts, targetProvider) { return `Encrypted content warning: ${total} rollout file(s) contain encrypted_content from provider(s) ${[...riskyProviders].sort().join(", ")}. Visibility metadata can be synchronized to ${targetProvider}, but continuing or compacting those histories may fail with invalid_encrypted_content. Return to the original provider/account or start a new session if you need reliable continuation.`; } -export async function getStatus({ codexHome: explicitCodexHome, sqliteHome, platform } = {}) { - const codexHome = normalizeCodexHome(explicitCodexHome); +export async function getStatus({ + codexHome: explicitCodexHome, + sqliteHome, + storage: providedStorage, + configText: providedConfigText, + platform +} = {}) { + const codexHome = providedStorage?.codexHome ?? normalizeCodexHome(explicitCodexHome); const configPath = path.join(codexHome, "config.toml"); - const configText = await readConfigText(configPath); - const storage = await prepareStorage({ codexHome, sqliteHome, configText, platform }); + const configText = providedConfigText ?? await readConfigText(configPath); + const storage = await prepareStorage({ codexHome, sqliteHome, configText, storage: providedStorage, platform }); const current = readCurrentProviderFromConfigText(configText); const configuredProviders = listConfiguredProviderIds(configText); const { @@ -395,6 +415,7 @@ async function runSyncCore({ storage: providedStorage, provider, configBackupText, + expectedConfigText, keepCount = DEFAULT_BACKUP_RETENTION_COUNT, sqliteBusyTimeoutMs, onProgress, @@ -417,6 +438,9 @@ async function runSyncCore({ await assertNoPendingTransactions(codexHome); throwIfAborted(signal); const configText = await readConfigText(configPath); + if (expectedConfigText !== undefined && configText !== expectedConfigText) { + throw new Error("config.toml changed after the operation was confirmed. Refresh and retry."); + } if (configBackupText !== undefined && configText !== configBackupText) { throw new Error("config.toml changed before the switch operation acquired its lock. Refresh and retry."); } @@ -575,7 +599,6 @@ async function runSyncCore({ appliedSessionChanges.push(change); sessionRestoreNeeded = true; await journal.applied("rollout", change.path); - await updateSessionBackupManifest(backupDir, appliedSessionChanges); await faultInjector?.({ point: "after_rollout_apply", path: change.path, appliedCount: appliedSessionChanges.length }); }, onSkipped: async (change, reason) => { @@ -645,6 +668,16 @@ async function runSyncCore({ await faultInjector?.({ point: "before_transaction_commit", completedCount: completedTargets.length }); await commitJournalWithReconciliation(journal, faultInjector); transactionCommitted = true; + // The transaction is committed and every target is on disk. Refreshing the + // inventory only corrects the recorded size and file count in + // metadata.json, so a failure must degrade to a warning: throwing would + // report a successful sync as failed and skip the pruning below. + let backupInventoryWarning = null; + try { + await refreshBackupInventory(backupDir); + } catch (inventoryError) { + backupInventoryWarning = `Backup inventory refresh failed: ${inventoryError instanceof Error ? inventoryError.message : String(inventoryError)}`; + } await faultInjector?.({ point: "after_transaction_commit", completedCount: completedTargets.length }); let autoPruneResult = null; let autoPruneWarning = null; @@ -664,6 +697,10 @@ async function runSyncCore({ deletedCount: autoPruneResult?.deletedCount ?? 0, warning: autoPruneWarning }); + autoPruneWarning = [backupInventoryWarning, autoPruneWarning] + .filter((part) => typeof part === "string" && part.trim().length > 0) + .map((part) => part.trim()) + .join(" | ") || null; const result = { codexHome, sqliteHome: storage.sqliteHome, @@ -797,6 +834,7 @@ async function runSyncCore({ // Preserve the original and rollback errors even if the journal is // no longer writable. } + await tryRefreshBackupInventory(backupDir); const persistedCompletedTargets = journalSnapshot ? uniqueResolvedPaths([...getAppliedJournalTargets(journalSnapshot), ...completedTargets]) : uniqueResolvedPaths(completedTargets); @@ -815,6 +853,7 @@ async function runSyncCore({ { rollbackStatus: "incomplete", recoveryRequired: true } ); } + await tryRefreshBackupInventory(backupDir); const persistedCompletedTargets = journalSnapshot ? uniqueResolvedPaths([...getAppliedJournalTargets(journalSnapshot), ...completedTargets]) : uniqueResolvedPaths(completedTargets); @@ -835,6 +874,8 @@ async function runSyncCore({ export async function runSwitch({ codexHome: explicitCodexHome, sqliteHome, + storage: providedStorage, + expectedConfigText, provider, model, keepRootModel = false, @@ -848,10 +889,13 @@ export async function runSwitch({ throw new Error("Missing provider id. Usage: codex-provider switch "); } - const codexHome = normalizeCodexHome(explicitCodexHome); + const codexHome = providedStorage?.codexHome ?? normalizeCodexHome(explicitCodexHome); const configPath = path.join(codexHome, "config.toml"); const originalConfigText = await readConfigText(configPath); - const storage = await prepareStorage({ codexHome, sqliteHome, configText: originalConfigText, platform }); + if (expectedConfigText !== undefined && originalConfigText !== expectedConfigText) { + throw new Error("config.toml changed after the operation was confirmed. Refresh and retry."); + } + const storage = await prepareStorage({ codexHome, sqliteHome, configText: originalConfigText, storage: providedStorage, platform }); assertSqliteAccessSupported(storage, "switch"); if (!storage.stateDbLocation && isConfiguredSqliteHome(storage)) { throw missingConfiguredStateDbError(storage); @@ -934,28 +978,34 @@ export async function runSwitch({ export async function runRestore({ codexHome: explicitCodexHome, sqliteHome, + storage: providedStorage, + expectedConfigText, backupDir, restoreConfig = true, restoreDatabase = true, restoreSessions = true, allowSqliteHomeRelocation = false, - platform + platform, + faultInjector }) { if (!backupDir) { throw new Error("Missing backup path. Usage: codex-provider restore "); } - const codexHome = normalizeCodexHome(explicitCodexHome); + const codexHome = providedStorage?.codexHome ?? normalizeCodexHome(explicitCodexHome); if (allowSqliteHomeRelocation && !(typeof sqliteHome === "string" && sqliteHome.trim())) { throw new Error("--allow-sqlite-home-relocation requires an explicit --sqlite-home path."); } - const configText = await readConfigText(path.join(codexHome, "config.toml")); - const storage = await prepareStorage({ codexHome, sqliteHome, configText, platform }); - assertSqliteAccessSupported(storage, "restore"); - if (restoreDatabase && !storage.stateDbLocation && isConfiguredSqliteHome(storage)) { - throw missingConfiguredStateDbError(storage); - } const releaseLock = await acquireLock(codexHome, "restore"); try { + const configText = await readConfigText(path.join(codexHome, "config.toml")); + if (expectedConfigText !== undefined && configText !== expectedConfigText) { + throw new Error("config.toml changed after the operation was confirmed. Refresh and retry."); + } + const storage = await prepareStorage({ codexHome, sqliteHome, configText, storage: providedStorage, platform }); + assertSqliteAccessSupported(storage, "restore"); + if (restoreDatabase && !storage.stateDbLocation && isConfiguredSqliteHome(storage)) { + throw missingConfiguredStateDbError(storage); + } const normalizedBackupDir = path.resolve(backupDir); let boundJournal = null; try { @@ -1013,6 +1063,17 @@ export async function runRestore({ allowSqliteHomeRelocation }); await markBackupTransactionRolledBack(normalizedBackupDir); + // The restore and its journal marker are already durable. Refreshing the + // inventory only corrects metadata.json bookkeeping, so surface a failure as + // a warning instead of reporting a completed restore as failed. + try { + await refreshBackupInventory(normalizedBackupDir, { faultInjector }); + } catch (inventoryError) { + return { + ...result, + backupInventoryWarning: `Backup inventory refresh failed: ${inventoryError instanceof Error ? inventoryError.message : String(inventoryError)}` + }; + } return result; } finally { await releaseLock(); diff --git a/src/session-files.js b/src/session-files.js index 2ae0e08..e147eb7 100644 --- a/src/session-files.js +++ b/src/session-files.js @@ -1,4 +1,4 @@ -import { execFile } from "node:child_process"; +import { execFile, spawn } from "node:child_process"; import fs from "node:fs"; import fsp from "node:fs/promises"; import os from "node:os"; @@ -467,24 +467,6 @@ function isValidWindowsRewriteResult(result) { || result === "SKIP_CHANGED"; } -function parseWindowsRewriteResults(stdout, changes) { - const trimmed = stdout.trim(); - const parsed = trimmed ? JSON.parse(trimmed) : []; - const results = Array.isArray(parsed) ? parsed : [parsed]; - - if (results.length !== changes.length) { - throw new Error(`Unexpected rewrite result count. Expected ${changes.length}, received ${results.length}.`); - } - - return results.map((entry, index) => { - const expectedPath = changes[index].path; - if (entry?.path !== expectedPath || !isValidWindowsRewriteResult(entry?.result)) { - throw new Error(`Unexpected rewrite result for ${expectedPath}: ${JSON.stringify(entry)}`); - } - return entry.result; - }); -} - async function restoreOriginalMtime(filePath, mtimeMs) { if (!Number.isFinite(mtimeMs)) { return; @@ -498,16 +480,22 @@ async function restoreOriginalMtime(filePath, mtimeMs) { } } -async function invokeWindowsExclusiveRewriteBatch(changes, { requireOriginalMatch }) { - if (!changes.length) { - return []; - } +const WINDOWS_REWRITE_PROTOCOL_VERSION = 1; +const WINDOWS_REWRITE_READY_TIMEOUT_MS = 15_000; - const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "codex-provider-rewrite-")); - const manifestPath = path.join(tempDir, "changes.json"); - const script = ` +const WINDOWS_EXCLUSIVE_REWRITE_WORKER_SCRIPT = ` & { - param([string]$manifestPath) + $ErrorActionPreference = "Stop" + $ProgressPreference = "SilentlyContinue" + $utf8 = [System.Text.UTF8Encoding]::new($false) + [Console]::InputEncoding = $utf8 + [Console]::OutputEncoding = $utf8 + + function Write-ProtocolMessage($value) { + $json = $value | ConvertTo-Json -Compress -Depth 8 + [Console]::Out.WriteLine($json) + [Console]::Out.Flush() + } function Read-FirstLineRecord([System.IO.FileStream]$stream) { $stream.Seek(0, [System.IO.SeekOrigin]::Begin) | Out-Null @@ -554,7 +542,7 @@ async function invokeWindowsExclusiveRewriteBatch(changes, { requireOriginalMatc try { $source = [System.IO.File]::Open($path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None) } catch { - if (Test-Path $path) { + if (Test-Path -LiteralPath $path) { return "SKIP_BUSY" } return "SKIP_CHANGED" @@ -604,7 +592,7 @@ async function invokeWindowsExclusiveRewriteBatch(changes, { requireOriginalMatc try { [System.IO.File]::Replace($tmpPath, $path, $replaceBackupPath, $true) } catch { - if (Test-Path $path) { + if (Test-Path -LiteralPath $path) { return "SKIP_BUSY" } return "SKIP_CHANGED" @@ -618,58 +606,285 @@ async function invokeWindowsExclusiveRewriteBatch(changes, { requireOriginalMatc if ($source) { $source.Dispose() } - Remove-Item -Path $tmpPath -Force -ErrorAction SilentlyContinue - Remove-Item -Path $replaceBackupPath -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $tmpPath -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $replaceBackupPath -Force -ErrorAction SilentlyContinue } } - $changes = Get-Content -Raw -Encoding UTF8 -Path $manifestPath | ConvertFrom-Json - if ($null -eq $changes) { - $changes = @() - } elseif ($changes -is [string] -or $changes -isnot [System.Collections.IEnumerable]) { - $changes = @($changes) - } else { - $changes = @($changes) - } + Write-ProtocolMessage ([ordered]@{ + protocolVersion = 1 + type = "ready" + }) - $results = @(foreach ($change in $changes) { - [pscustomobject]@{ - path = [string]$change.path - result = Invoke-RewriteChange $change + while ($null -ne ($line = [Console]::In.ReadLine())) { + if ([string]::IsNullOrWhiteSpace($line)) { + continue } - }) - $results | ConvertTo-Json -Compress + $request = $null + try { + $request = $line | ConvertFrom-Json + $requestPath = [string]$request.path + if (([int]$request.protocolVersion -ne 1) -or + ([string]$request.type -ne "rewrite") -or + ($null -eq $request.id) -or + [string]::IsNullOrWhiteSpace($requestPath) -or + (-not [System.IO.Path]::IsPathRooted($requestPath))) { + throw [System.InvalidOperationException]::new("Invalid Windows rewrite worker request.") + } + + $result = Invoke-RewriteChange $request + Write-ProtocolMessage ([ordered]@{ + protocolVersion = 1 + type = "result" + id = $request.id + path = $requestPath + result = $result + }) + } catch { + [Console]::Error.WriteLine($_.Exception.ToString()) + [Console]::Error.Flush() + $errorId = $null + $errorPath = $null + if ($null -ne $request) { + $errorId = $request.id + $errorPath = [string]$request.path + } + Write-ProtocolMessage ([ordered]@{ + protocolVersion = 1 + type = "error" + id = $errorId + path = $errorPath + message = $_.Exception.Message + }) + exit 1 + } + } } `.trim(); - try { - const manifestChanges = changes.map((change) => ({ - ...change, - requireOriginalMatch - })); - await fsp.writeFile( - manifestPath, - JSON.stringify(manifestChanges), - "utf8" - ); +function formatWindowsRewriteWorkerError(message, stderr) { + const detail = stderr.trim(); + return detail ? `${message} PowerShell diagnostics: ${detail}` : message; +} - const { stdout } = await execFileAsync("powershell.exe", [ +function writeWorkerRequest(stream, request) { + return new Promise((resolve, reject) => { + stream.write(`${JSON.stringify(request)}\n`, "utf8", (error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); +} + +export async function createWindowsExclusiveRewriteWorker(options = {}) { + const { + spawnImpl = spawn, + readyTimeoutMs = WINDOWS_REWRITE_READY_TIMEOUT_MS + } = options; + const child = spawnImpl("powershell.exe", [ + "-NoLogo", "-NoProfile", + "-NonInteractive", + "-InputFormat", + "Text", + "-OutputFormat", + "Text", "-ExecutionPolicy", "Bypass", "-Command", - script, - manifestPath + WINDOWS_EXCLUSIVE_REWRITE_WORKER_SCRIPT ], { - maxBuffer: 16 * 1024 * 1024 + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true + }); + + let stderr = ""; + let spawnError = null; + let exitInfo = null; + let closed = false; + let inFlight = false; + let nextRequestId = 1; + let stdinError = null; + const stdoutLines = readline.createInterface({ + input: child.stdout, + crlfDelay: Infinity + }); + const stdoutIterator = stdoutLines[Symbol.asyncIterator](); + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk) => { + stderr = `${stderr}${chunk}`.slice(-64 * 1024); + }); + child.stdin.on("error", (error) => { + stdinError = error; + }); + + const completion = new Promise((resolve) => { + child.once("error", (error) => { + spawnError = error; + resolve({ error, code: null, signal: null }); + }); + child.once("exit", (code, signal) => { + exitInfo = { error: null, code, signal }; + resolve(exitInfo); }); + }); + + async function readProtocolMessage(timeoutMs = null) { + let timeoutId = null; + const timeoutPromise = timeoutMs === null + ? null + : new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new Error(`Windows rewrite worker did not become ready within ${timeoutMs} ms.`)); + }, timeoutMs); + }); + try { + const nextLine = timeoutPromise + ? await Promise.race([stdoutIterator.next(), timeoutPromise]) + : await stdoutIterator.next(); + if (nextLine.done) { + const message = spawnError + ? `Unable to start Windows rewrite worker: ${spawnError.message}` + : `Windows rewrite worker closed stdout unexpectedly${exitInfo ? ` (exit ${exitInfo.code ?? "null"}, signal ${exitInfo.signal ?? "null"})` : ""}.`; + throw new Error(formatWindowsRewriteWorkerError(message, stderr)); + } + try { + return JSON.parse(nextLine.value); + } catch (error) { + throw new Error( + formatWindowsRewriteWorkerError( + `Windows rewrite worker returned malformed JSON: ${error.message}`, + stderr + ) + ); + } + } finally { + if (timeoutId !== null) { + clearTimeout(timeoutId); + } + } + } - return parseWindowsRewriteResults(stdout, changes); + try { + const ready = await readProtocolMessage(readyTimeoutMs); + if (ready?.protocolVersion !== WINDOWS_REWRITE_PROTOCOL_VERSION || ready?.type !== "ready") { + throw new Error(`Unexpected Windows rewrite worker ready message: ${JSON.stringify(ready)}`); + } } catch (error) { - throw wrapRolloutFileBusyError(error, changes[0]?.path, "rewrite"); + child.stdin.destroy(); + child.kill(); + stdoutLines.close(); + throw error; + } + + return { + pid: child.pid, + async rewrite(change, { requireOriginalMatch }) { + if (closed) { + throw new Error("Windows rewrite worker is already closed."); + } + if (inFlight) { + throw new Error("Windows rewrite worker already has an in-flight request."); + } + if (!change || typeof change.path !== "string" || !path.isAbsolute(change.path)) { + throw new Error(`Windows rewrite worker requires an absolute rollout path: ${change?.path ?? "(missing)"}`); + } + + const id = nextRequestId; + nextRequestId += 1; + inFlight = true; + try { + await writeWorkerRequest(child.stdin, { + ...change, + protocolVersion: WINDOWS_REWRITE_PROTOCOL_VERSION, + type: "rewrite", + id, + requireOriginalMatch: Boolean(requireOriginalMatch) + }); + const response = await readProtocolMessage(); + if (response?.protocolVersion !== WINDOWS_REWRITE_PROTOCOL_VERSION + || response?.type !== "result" + || response?.id !== id + || response?.path !== change.path + || !isValidWindowsRewriteResult(response?.result)) { + throw new Error(`Unexpected Windows rewrite worker response for ${change.path}: ${JSON.stringify(response)}`); + } + return response.result; + } catch (error) { + child.stdin.destroy(); + child.kill(); + throw wrapRolloutFileBusyError( + new Error( + formatWindowsRewriteWorkerError( + `Windows rewrite worker failed for ${change.path}: ${stdinError?.message ?? error.message}`, + stderr + ), + { cause: error } + ), + change.path, + "rewrite" + ); + } finally { + inFlight = false; + } + }, + async close() { + if (closed) { + return; + } + closed = true; + if (!child.stdin.destroyed) { + child.stdin.end(); + } + const completed = await completion; + stdoutLines.close(); + if (completed.error) { + throw new Error(formatWindowsRewriteWorkerError( + `Windows rewrite worker failed to start: ${completed.error.message}`, + stderr + )); + } + if (completed.code !== 0) { + throw new Error(formatWindowsRewriteWorkerError( + `Windows rewrite worker exited with code ${completed.code ?? "null"} and signal ${completed.signal ?? "null"}.`, + stderr + )); + } + } + }; +} + +async function invokeWindowsExclusiveRewriteBatch(changes, { requireOriginalMatch }) { + if (!changes.length) { + return []; + } + + let worker = null; + let primaryError = null; + try { + worker = await createWindowsExclusiveRewriteWorker(); + const results = []; + for (const change of changes) { + results.push(await worker.rewrite(change, { requireOriginalMatch })); + } + return results; + } catch (error) { + primaryError = error; + throw error; } finally { - await fsp.rm(tempDir, { recursive: true, force: true }); + if (worker) { + try { + await worker.close(); + } catch (closeError) { + if (!primaryError) { + throw closeError; + } + } + } } } @@ -1095,7 +1310,8 @@ export async function applySessionChanges(changes, options = {}) { onBeforeApply, onMutation, onApplied, - onSkipped + onSkipped, + windowsRewriteWorkerFactory = createWindowsExclusiveRewriteWorker } = options ?? {}; const skippedPaths = []; const appliedPaths = []; @@ -1113,31 +1329,50 @@ export async function applySessionChanges(changes, options = {}) { const firstLineChanges = normalizedChanges.filter((change) => !change?.modelOnlyChange); if (process.platform === "win32") { - // Process one file per helper invocation. A failed Windows batch cannot - // report which earlier members were already replaced, which was the root - // cause of #69. Per-target calls let the durable coordinator observe each - // successful mutation before the next target starts. - for (const change of firstLineChanges) { - await onBeforeApply?.(change); - const [result] = await invokeWindowsExclusiveRewriteBatch([change], { requireOriginalMatch: true }); - if (result === "APPLIED" || result === "APPLIED_IN_PLACE") { - appliedChanges += 1; - inPlaceChanges += result === "APPLIED_IN_PLACE" ? 1 : 0; - appliedPaths.push(change.path); - await onMutation?.(change, { stage: "firstLine", result }); - if (change.modelRewriteRequired) { - const modelResult = await rewriteRolloutModelField(change, targetModel); - retainOrValidateModelSnapshot(change, modelResult.originalTurnContextModels); - change.appliedTurnContextRewrites = modelResult.replacedLines; - if (modelResult.replacedLines > 0) { - await onMutation?.(change, { stage: "model", result: "APPLIED" }); + // Keep one PowerShell process alive, but send exactly one target at a time. + // The coordinator persists applying/applied around each awaited request, so + // an abrupt exit can never mutate a later rollout that has no journal entry. + let worker = null; + let primaryError = null; + try { + if (firstLineChanges.length > 0) { + worker = await windowsRewriteWorkerFactory(); + } + for (const change of firstLineChanges) { + await onBeforeApply?.(change); + const result = await worker.rewrite(change, { requireOriginalMatch: true }); + if (result === "APPLIED" || result === "APPLIED_IN_PLACE") { + appliedChanges += 1; + inPlaceChanges += result === "APPLIED_IN_PLACE" ? 1 : 0; + appliedPaths.push(change.path); + await onMutation?.(change, { stage: "firstLine", result }); + if (change.modelRewriteRequired) { + const modelResult = await rewriteRolloutModelField(change, targetModel); + retainOrValidateModelSnapshot(change, modelResult.originalTurnContextModels); + change.appliedTurnContextRewrites = modelResult.replacedLines; + if (modelResult.replacedLines > 0) { + await onMutation?.(change, { stage: "model", result: "APPLIED" }); + } + } + await restoreOriginalMtime(change.path, change.originalMtimeMs); + await onApplied?.(change); + } else { + skippedPaths.push(change.path); + await onSkipped?.(change, result); + } + } + } catch (error) { + primaryError = error; + throw error; + } finally { + if (worker) { + try { + await worker.close(); + } catch (closeError) { + if (!primaryError) { + throw closeError; } } - await restoreOriginalMtime(change.path, change.originalMtimeMs); - await onApplied?.(change); - } else { - skippedPaths.push(change.path); - await onSkipped?.(change, result); } } } else { diff --git a/src/web-server.js b/src/web-server.js new file mode 100644 index 0000000..9bf5d55 --- /dev/null +++ b/src/web-server.js @@ -0,0 +1,1139 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import http from "node:http"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +import { listBackups } from "./backup.js"; +import { readConfigText, readRootModelFromConfigText } from "./config-file.js"; +import { defaultCodexHome } from "./constants.js"; +import { getHistorySession, listHistory } from "./history.js"; +import { getStatus, runPruneBackups, runRestore, runSwitch, runSync } from "./service.js"; +import { detectStateDb } from "./sqlite-state.js"; +import { ensureCodexHome, resolveStorageLayout, withStateDbLocation } from "./storage-layout.js"; +import { createMemoryWebUiState, ProfileRevisionConflictError, WebUiStateStore } from "./web-state.js"; + +const DEFAULT_PORT = 8791; +const MAX_REQUEST_BYTES = 64 * 1024; +const ACTIVITY_LIMIT = 250; +const PAIRING_TTL_MS = 5 * 60 * 1000; +const INTERNAL_PROTOCOL_VERSION = 2; +const INTERNAL_NONCE_BYTES = 32; +const INTERNAL_CHALLENGE_TTL_MS = 30 * 1000; +const INTERNAL_CHALLENGE_LIMIT = 128; +const INTERNAL_REQUEST_DOMAIN = "codex-provider-sync:web-ui:internal-pairing:v2:request"; +const INTERNAL_RESPONSE_DOMAIN = "codex-provider-sync:web-ui:internal-pairing:v2:response"; +const DEVICE_SECRET_BYTES = 32; +const STATE_FILENAME = "provider-sync-web.json"; +const RUNTIME_FILENAME = "provider-sync-web.runtime.json"; +const WEB_ROOT = fileURLToPath(new URL("../web/dist/", import.meta.url)); +const MIME_TYPES = new Map([ + [".css", "text/css; charset=utf-8"], + [".html", "text/html; charset=utf-8"], + [".ico", "image/x-icon"], + [".js", "text/javascript; charset=utf-8"], + [".json", "application/json; charset=utf-8"], + [".map", "application/json; charset=utf-8"], + [".png", "image/png"], + [".svg", "image/svg+xml"], + [".webp", "image/webp"] +]); + +function sendJson(response, statusCode, value) { + const body = JSON.stringify(value); + response.writeHead(statusCode, { + "Cache-Control": "no-store", + "Content-Type": "application/json; charset=utf-8", + "Content-Length": Buffer.byteLength(body), + "X-Content-Type-Options": "nosniff" + }); + response.end(body); +} + +function sendError(response, statusCode, error, code) { + const message = error instanceof Error ? error.message : String(error); + sendJson(response, statusCode, { error: message, ...(code ? { code } : {}) }); +} + +async function readJsonBody(request) { + const chunks = []; + let received = 0; + for await (const chunk of request) { + received += chunk.length; + if (received > MAX_REQUEST_BYTES) { + throw new Error(`Request body exceeds ${MAX_REQUEST_BYTES} bytes.`); + } + chunks.push(chunk); + } + if (chunks.length === 0) { + return {}; + } + try { + return JSON.parse(Buffer.concat(chunks).toString("utf8")); + } catch { + throw new Error("Request body must be valid JSON."); + } +} + +function requireString(value, label, { optional = false, maxLength = 4096 } = {}) { + if (value === undefined || value === null || value === "") { + if (optional) { + return undefined; + } + throw new Error(`${label} is required.`); + } + if (typeof value !== "string" || !value.trim()) { + throw new Error(`${label} must be a non-empty string.`); + } + const normalized = value.trim(); + if (normalized.length > maxLength) { + throw new Error(`${label} is too long.`); + } + return normalized; +} + +function requireProvider(value) { + const provider = requireString(value, "provider", { maxLength: 200 }); + if (!/^[A-Za-z0-9_.-]+$/.test(provider)) { + throw new Error("provider may only contain letters, numbers, dots, underscores, and hyphens."); + } + return provider; +} + +function requireKeepCount(value, { allowZero = false } = {}) { + const minimum = allowZero ? 0 : 1; + if (!Number.isInteger(value) || value < minimum || value > 100000) { + throw new Error(`keepCount must be an integer between ${minimum} and 100000.`); + } + return value; +} + +function resolveStorageProfile(input, stateStore) { + if (Object.hasOwn(input ?? {}, "codexHome") || Object.hasOwn(input ?? {}, "sqliteHome")) { + throw new Error("Storage paths must be selected through a server-managed profileId."); + } + const profileId = requireString(input?.profileId ?? "default", "profileId", { maxLength: 80 }); + const profile = stateStore.getProfile(profileId); + return { + profileId: profile.id, + profileRevision: profile.revision, + codexHome: profile.codexHome, + ...(profile.sqliteHome ? { sqliteHome: profile.sqliteHome } : {}) + }; +} + +function captureProfileRevision(profileId, suppliedRevision, stateStore, response) { + const profile = stateStore.getProfile(profileId); + if (typeof suppliedRevision !== "string" || !suppliedRevision) { + sendJson(response, 409, { + error: "This operation requires the current storage profile revision. Refresh the profile and try again.", + code: "PROFILE_REVISION_REQUIRED", + profile + }); + return null; + } + if (suppliedRevision !== profile.revision) { + sendJson(response, 409, { + error: "The storage profile changed after this operation was prepared. Refresh and confirm again.", + code: "PROFILE_CHANGED", + profile + }); + return null; + } + return profile; +} + +function captureStorageProfile(input, stateStore, response) { + if (Object.hasOwn(input ?? {}, "codexHome") || Object.hasOwn(input ?? {}, "sqliteHome")) { + throw new Error("Storage paths must be selected through a server-managed profileId."); + } + const profileId = requireString(input?.profileId ?? "default", "profileId", { maxLength: 80 }); + const profile = captureProfileRevision(profileId, input?.profileRevision, stateStore, response); + if (!profile) return null; + const snapshot = { + profileId: profile.id, + codexHome: profile.codexHome, + ...(profile.sqliteHome ? { sqliteHome: profile.sqliteHome } : {}) + }; + return Object.freeze(snapshot); +} + +function comparableStoragePath(value, platform) { + if (typeof value !== "string") return null; + return platform === "win32" ? value.toLowerCase() : value; +} + +function storageRevision(profile, storage, configText, platform) { + const canonical = JSON.stringify({ + version: 2, + profileId: profile.profileId ?? profile.id, + profileRevision: profile.profileRevision ?? profile.revision, + configRevision: crypto.createHash("sha256").update(configText, "utf8").digest("base64url"), + codexHome: comparableStoragePath(storage.codexHome, platform), + sqliteHome: comparableStoragePath(storage.sqliteHome, platform), + sqliteHomeSource: storage.sqliteHomeSource, + sqliteAccess: { + supported: storage.sqliteAccess?.supported !== false, + reason: storage.sqliteAccess?.reason ?? null + }, + allowLegacyRootFallback: Boolean(storage.allowLegacyRootFallback), + stateDbLocation: storage.stateDbLocation + ? { + path: comparableStoragePath(storage.stateDbLocation.path, platform), + source: storage.stateDbLocation.source + } + : null + }); + return crypto.createHash("sha256").update(canonical, "utf8").digest("base64url"); +} + +function serializeStatus(status) { + const rollout = status.rolloutCounts ?? { sessions: {}, archived_sessions: {} }; + const sqlite = status.sqliteCounts; + const targetProvider = status.currentProvider; + const matchesTargetProvider = (distribution) => ["sessions", "archived_sessions"].every((scope) => ( + Object.entries(distribution?.[scope] ?? {}).every(([provider, count]) => count === 0 || provider === targetProvider) + )); + const sqliteReadable = Boolean(sqlite && !sqlite.unreadable); + const rolloutScanComplete = !status.lockedRolloutFiles?.length; + return { + ...status, + alignment: { + aligned: Boolean( + targetProvider + && sqliteReadable + && rolloutScanComplete + && matchesTargetProvider(rollout) + && matchesTargetProvider(sqlite) + ), + sqliteReadable, + targetProvider + } + }; +} + +function stageMessage(event) { + const messages = { + scan_rollout_files: "Scanning rollout files", + check_locked_rollout_files: "Checking locked rollout files", + create_backup: "Creating backup", + update_config: "Updating config.toml", + update_sqlite: "Updating SQLite metadata", + rewrite_rollout_files: "Rewriting rollout metadata", + clean_backups: "Cleaning old backups" + }; + const base = messages[event?.stage] ?? event?.stage ?? "Operation progress"; + return event?.status === "complete" ? `${base} complete` : base; +} + +function openLocalUrl(url, platform = process.platform) { + let command; + let args; + if (platform === "win32") { + command = "cmd"; + args = ["/d", "/s", "/c", "start", "", url]; + } else if (platform === "darwin") { + command = "open"; + args = [url]; + } else { + command = "xdg-open"; + args = [url]; + } + return new Promise((resolve) => { + let settled = false; + const finish = (opened) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + resolve(opened); + }; + const child = spawn(command, args, { stdio: "ignore" }); + const timeout = setTimeout(() => { + child.unref(); + finish(true); + }, 3000); + child.once("error", () => finish(false)); + child.once("exit", (code) => finish(code === 0)); + }); +} + +function canOpenLocalUrl(platform = process.platform, environment = process.env) { + if (platform !== "linux") return true; + return Boolean(environment.DISPLAY || environment.WAYLAND_DISPLAY); +} + +async function serveStatic(response, pathname, webRoot) { + let relativePath = pathname === "/" ? "index.html" : pathname.slice(1); + try { + relativePath = decodeURIComponent(relativePath); + } catch { + sendError(response, 400, "Invalid URL encoding."); + return; + } + + const root = path.resolve(webRoot); + let filePath = path.resolve(root, relativePath); + if (filePath !== root && !filePath.startsWith(`${root}${path.sep}`)) { + sendError(response, 403, "Static path is outside the Web UI root."); + return; + } + + let file; + try { + file = await fs.readFile(filePath); + } catch (error) { + if (error?.code !== "ENOENT" || path.extname(relativePath)) { + sendError(response, error?.code === "ENOENT" ? 404 : 500, error); + return; + } + filePath = path.join(root, "index.html"); + file = await fs.readFile(filePath); + } + + const extension = path.extname(filePath).toLowerCase(); + if (extension === ".html") { + file = Buffer.from(file.toString("utf8").replace("__CODEX_PROVIDER_SYNC_BOOTSTRAP__", "{}"), "utf8"); + } + response.writeHead(200, { + "Cache-Control": extension === ".html" ? "no-store" : "public, max-age=3600", + "Content-Type": MIME_TYPES.get(extension) ?? "application/octet-stream", + "Content-Length": file.length, + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'" + }); + response.end(file); +} + +function randomSecret() { + return crypto.randomBytes(DEVICE_SECRET_BYTES).toString("base64url"); +} + +function secretDigest(secret) { + return crypto.createHash("sha256").update(String(secret), "utf8").digest(); +} + +function secretsMatch(secret, expectedDigest) { + if (typeof secret !== "string" || !expectedDigest) return false; + const candidate = secretDigest(secret); + return candidate.length === expectedDigest.length && crypto.timingSafeEqual(candidate, expectedDigest); +} + +function internalChallengeRequestPayload({ port, instanceId }) { + return { + protocolVersion: INTERNAL_PROTOCOL_VERSION, + port, + instanceId + }; +} + +function internalChallengePayload({ port, instanceId, nonce }) { + return { + protocolVersion: INTERNAL_PROTOCOL_VERSION, + port, + instanceId, + nonce + }; +} + +function internalRequestPayload({ port, instanceId, nonce, resetAccess }) { + return { + protocolVersion: INTERNAL_PROTOCOL_VERSION, + port, + instanceId, + nonce, + resetAccess: Boolean(resetAccess) + }; +} + +function internalResponsePayload({ port, instanceId, nonce, resetAccess, pairingToken }) { + return { + protocolVersion: INTERNAL_PROTOCOL_VERSION, + port, + instanceId, + nonce, + resetAccess: Boolean(resetAccess), + pairingToken + }; +} + +function internalProof(secret, domain, payload) { + return crypto.createHmac("sha256", secret) + .update(`${domain}\n${JSON.stringify(payload)}`, "utf8") + .digest("base64url"); +} + +function internalProofsMatch(supplied, expected) { + if (typeof supplied !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(supplied)) return false; + const candidate = Buffer.from(supplied, "base64url"); + const expectedBuffer = Buffer.from(expected, "base64url"); + return candidate.length === expectedBuffer.length && crypto.timingSafeEqual(candidate, expectedBuffer); +} + +function validSecretToken(value) { + return typeof value === "string" + && /^[A-Za-z0-9_-]{43}$/.test(value) + && Buffer.from(value, "base64url").length === INTERNAL_NONCE_BYTES; +} + +function validInternalNonce(value) { + return validSecretToken(value); +} + +function isLoopbackHostname(hostname) { + const normalized = String(hostname).toLowerCase().replace(/^\[|\]$/g, ""); + return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1"; +} + +function validBrowserOrigin(request, { required = false } = {}) { + const value = request.headers.origin; + if (!value) return !required; + const host = request.headers.host; + if (!host) return false; + try { + const originUrl = new URL(value); + const hostUrl = new URL(`http://${host}`); + return originUrl.protocol === "http:" + && isLoopbackHostname(originUrl.hostname) + && isLoopbackHostname(hostUrl.hostname) + && originUrl.host.toLowerCase() === hostUrl.host.toLowerCase(); + } catch { + return false; + } +} + +export function createWebUiServer({ + webRoot = WEB_ROOT, + services = {}, + stateStore = createMemoryWebUiState({ codexHome: defaultCodexHome() }), + internalSecret = randomSecret(), + instanceId = randomSecret(), + pairingTtlMs = PAIRING_TTL_MS, + now = () => Date.now(), + platform = process.platform, + environment = process.env +} = {}) { + const api = { + getStatus: services.getStatus ?? getStatus, + listBackups: services.listBackups ?? listBackups, + runSync: services.runSync ?? runSync, + runSwitch: services.runSwitch ?? runSwitch, + runRestore: services.runRestore ?? runRestore, + runPruneBackups: services.runPruneBackups ?? runPruneBackups, + readConfigText: services.readConfigText ?? readConfigText, + readRootModelFromConfigText: services.readRootModelFromConfigText ?? readRootModelFromConfigText, + listHistory: services.listHistory ?? listHistory, + getHistorySession: services.getHistorySession ?? getHistorySession + }; + const activity = []; + let activityId = 0; + let activeOperation = null; + let baseUrl = null; + let pairing = null; + const internalChallenges = new Map(); + + const record = (level, message, detail = null, operation = activeOperation?.kind ?? null) => { + activityId += 1; + activity.push({ id: activityId, timestamp: new Date().toISOString(), level, message, detail, operation }); + if (activity.length > ACTIVITY_LIMIT) { + activity.splice(0, activity.length - ACTIVITY_LIMIT); + } + }; + + const issuePairing = () => { + const secret = randomSecret(); + pairing = { + digest: secretDigest(secret), + expiresAt: now() + pairingTtlMs + }; + return secret; + }; + + const authorize = (request) => { + return stateStore.hasCredential(request.headers["x-codex-provider-device"]); + }; + + const requireAuthorizedBrowser = (request, response, { originRequired = request.method !== "GET" } = {}) => { + if (!validBrowserOrigin(request, { required: originRequired })) { + sendError(response, 403, "Invalid browser Origin for this loopback request.", "INVALID_ORIGIN"); + return false; + } + if (!authorize(request)) { + sendError(response, 403, "This browser is not paired with the Web UI.", "PAIRING_REQUIRED"); + return false; + } + return true; + }; + + const withOperation = async (kind, response, operation) => { + if (activeOperation) { + sendJson(response, 409, { error: `Another operation is already running: ${activeOperation.kind}.` }); + return; + } + activeOperation = { kind, startedAt: new Date().toISOString() }; + record("info", `${kind} started`); + try { + const result = await operation(); + const outcome = Array.isArray(result?.skippedLockedRolloutFiles) && result.skippedLockedRolloutFiles.length > 0 + ? "partial" + : "success"; + record(outcome === "partial" ? "warning" : "success", `${kind} completed`); + sendJson(response, 200, { result: { ...result, outcome } }); + } catch (error) { + record("error", `${kind} failed`, error instanceof Error ? error.message : String(error)); + sendError(response, 400, error); + } finally { + activeOperation = null; + } + }; + + const resolveOperationStorage = async (profile) => { + let configText = ""; + try { + configText = await api.readConfigText(path.join(profile.codexHome, "config.toml")); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + const layout = resolveStorageLayout({ + codexHome: profile.codexHome, + sqliteHome: profile.sqliteHome, + configText, + env: environment, + platform + }); + await ensureCodexHome(layout); + const storage = layout.sqliteAccess.supported === false + ? withStateDbLocation(layout, null) + : withStateDbLocation(layout, await detectStateDb(layout)); + return { configText, storage }; + }; + + const captureOperationStorage = async (input, response) => { + if (Object.hasOwn(input ?? {}, "codexHome") || Object.hasOwn(input ?? {}, "sqliteHome")) { + throw new Error("Storage paths must be selected through a server-managed profileId."); + } + const profileId = requireString(input?.profileId ?? "default", "profileId", { maxLength: 80 }); + const profile = captureProfileRevision(profileId, input?.profileRevision, stateStore, response); + if (!profile) return null; + if (typeof input?.storageRevision !== "string" || !input.storageRevision) { + sendJson(response, 409, { + error: "This operation requires the confirmed SQLite storage revision. Refresh and confirm again.", + code: "STORAGE_REVISION_REQUIRED", + profile + }); + return null; + } + const prepared = await resolveOperationStorage(profile); + if (input.storageRevision !== storageRevision(profile, prepared.storage, prepared.configText, platform)) { + sendJson(response, 409, { + error: "The configuration or effective SQLite storage changed after this operation was prepared. Refresh and confirm again.", + code: "STORAGE_CHANGED", + profile + }); + return null; + } + return { + profile: Object.freeze({ + profileId: profile.id, + codexHome: profile.codexHome, + ...(profile.sqliteHome ? { sqliteHome: profile.sqliteHome } : {}) + }), + ...prepared + }; + }; + + const assertWebOperationStorage = (storage, operation) => { + if (storage.sqliteAccess.supported === false) { + throw new Error(`Cannot ${operation}: ${storage.sqliteAccess.message}`); + } + return storage; + }; + + const server = http.createServer(async (request, response) => { + const requestUrl = new URL(request.url ?? "/", baseUrl ?? "http://127.0.0.1"); + const pathname = requestUrl.pathname; + + try { + if (request.method === "POST" && pathname === "/api/pair") { + if (!validBrowserOrigin(request, { required: true })) { + sendError(response, 403, "Invalid browser Origin for pairing.", "INVALID_ORIGIN"); + return; + } + const supplied = request.headers["x-codex-provider-pairing"]; + if (!pairing || pairing.expiresAt < now() || !secretsMatch(supplied, pairing.digest)) { + sendError(response, 403, "The pairing link is invalid, expired, or already used.", "PAIRING_REQUIRED"); + return; + } + pairing = null; + const deviceCredential = randomSecret(); + await stateStore.addCredential(deviceCredential); + sendJson(response, 200, { deviceCredential }); + return; + } + + if (request.method === "POST" && pathname === "/api/internal/challenge") { + const body = await readJsonBody(request); + const address = server.address(); + const actualPort = typeof address === "object" && address ? address.port : null; + if (body.protocolVersion !== INTERNAL_PROTOCOL_VERSION + || body.port !== actualPort + || body.instanceId !== instanceId) { + sendError(response, 403, "Invalid Web UI authentication challenge.", "INVALID_INTERNAL_CHALLENGE"); + return; + } + const currentTime = now(); + for (const [nonce, expiresAt] of internalChallenges) { + if (expiresAt <= currentTime) internalChallenges.delete(nonce); + } + const nonce = crypto.randomBytes(INTERNAL_NONCE_BYTES).toString("base64url"); + internalChallenges.set(nonce, currentTime + INTERNAL_CHALLENGE_TTL_MS); + while (internalChallenges.size > INTERNAL_CHALLENGE_LIMIT) { + internalChallenges.delete(internalChallenges.keys().next().value); + } + sendJson(response, 200, internalChallengePayload({ port: actualPort, instanceId, nonce })); + return; + } + + if (request.method === "POST" && pathname === "/api/internal/new-pairing") { + const body = await readJsonBody(request); + const address = server.address(); + const actualPort = typeof address === "object" && address ? address.port : null; + if (body.protocolVersion !== INTERNAL_PROTOCOL_VERSION + || body.port !== actualPort + || body.instanceId !== instanceId + || !validInternalNonce(body.nonce) + || typeof body.resetAccess !== "boolean") { + sendError(response, 403, "Invalid authenticated Web UI pairing request.", "INVALID_INTERNAL_PROOF"); + return; + } + const signedRequest = internalRequestPayload(body); + const expectedProof = internalProof(internalSecret, INTERNAL_REQUEST_DOMAIN, signedRequest); + if (!internalProofsMatch(request.headers["x-codex-provider-internal-proof"], expectedProof)) { + sendError(response, 403, "Invalid authenticated Web UI pairing request.", "INVALID_INTERNAL_PROOF"); + return; + } + const currentTime = now(); + for (const [nonce, expiresAt] of internalChallenges) { + if (expiresAt <= currentTime) internalChallenges.delete(nonce); + } + if (!internalChallenges.has(body.nonce)) { + sendError(response, 403, "The Web UI authentication challenge is missing, expired, or already used.", "INTERNAL_CHALLENGE_REQUIRED"); + return; + } + internalChallenges.delete(body.nonce); + if (body.resetAccess) await stateStore.resetCredentials(); + const payload = internalResponsePayload({ + port: actualPort, + instanceId, + nonce: body.nonce, + resetAccess: body.resetAccess, + pairingToken: issuePairing() + }); + sendJson(response, 200, { + ...payload, + proof: internalProof(internalSecret, INTERNAL_RESPONSE_DOMAIN, payload) + }); + return; + } + + if (request.method === "GET" && pathname === "/api/health") { + sendJson(response, 200, { ok: true, service: "codex-provider-sync", activeOperation }); + return; + } + + if (pathname.startsWith("/api/")) { + if (!requireAuthorizedBrowser(request, response)) { + return; + } + + if (request.method === "GET" && pathname === "/api/profiles") { + sendJson(response, 200, { profiles: stateStore.listProfiles() }); + return; + } + + if (request.method === "GET" && pathname === "/api/activity") { + const after = Number.parseInt(requestUrl.searchParams.get("after") ?? "0", 10) || 0; + sendJson(response, 200, { activity: activity.filter((entry) => entry.id > after), activeOperation }); + return; + } + + if (request.method !== "POST") { + sendError(response, 405, "API endpoint requires POST."); + return; + } + + const body = await readJsonBody(request); + if (pathname === "/api/profiles/save") { + const profileId = requireString(body.profileId, "profileId", { maxLength: 80 }); + try { + const profile = await stateStore.saveProfile({ + id: profileId, + name: requireString(body.name, "name", { maxLength: 120 }), + codexHome: requireString(body.codexHome, "codexHome"), + sqliteHome: requireString(body.sqliteHome, "sqliteHome", { optional: true }) + }, { expectedRevision: body.profileRevision }); + sendJson(response, 200, { profile }); + } catch (error) { + if (!(error instanceof ProfileRevisionConflictError)) throw error; + sendJson(response, 409, { + error: error.message, + code: error.code, + profile: error.profile + }); + } + return; + } + + if (pathname === "/api/profiles/delete") { + const profileId = requireString(body.profileId, "profileId", { maxLength: 80 }); + if (!captureProfileRevision(profileId, body.profileRevision, stateStore, response)) return; + await stateStore.deleteProfile(profileId); + sendJson(response, 200, { ok: true }); + return; + } + + if (pathname === "/api/access/forget") { + await stateStore.removeCredential(request.headers["x-codex-provider-device"]); + sendJson(response, 200, { ok: true }); + return; + } + + if (pathname === "/api/status") { + const profile = resolveStorageProfile(body, stateStore); + const prepared = await resolveOperationStorage(profile); + const status = serializeStatus(await api.getStatus({ + ...profile, + storage: prepared.storage, + configText: prepared.configText + })); + status.pathComparisonCaseInsensitive = platform === "win32"; + status.profileId = profile.profileId; + status.profileRevision = profile.profileRevision; + status.storageRevision = storageRevision(profile, prepared.storage, prepared.configText, platform); + record("info", "Status refreshed", status.codexHome, null); + sendJson(response, 200, { status }); + return; + } + + if (pathname === "/api/backups") { + const { codexHome } = resolveStorageProfile(body, stateStore); + sendJson(response, 200, await api.listBackups(codexHome)); + return; + } + + if (pathname === "/api/history") { + const storage = resolveStorageProfile(body, stateStore); + const history = await api.listHistory(storage.codexHome, body); + sendJson(response, 200, { history }); + return; + } + + if (pathname === "/api/history/session") { + const storage = resolveStorageProfile(body, stateStore); + const sessionId = requireString(body.sessionId, "sessionId", { maxLength: 300 }); + const history = await api.getHistorySession(storage.codexHome, sessionId); + sendJson(response, 200, { history }); + return; + } + + if (pathname === "/api/sync") { + const operationStorage = await captureOperationStorage(body, response); + if (!operationStorage) return; + await withOperation("sync", response, async () => { + assertWebOperationStorage(operationStorage.storage, "sync"); + const provider = requireProvider(body.provider); + const keepCount = requireKeepCount(body.keepCount); + const model = api.readRootModelFromConfigText(operationStorage.configText); + return api.runSync({ + ...operationStorage.profile, + storage: operationStorage.storage, + expectedConfigText: operationStorage.configText, + provider, + keepCount, + model, + onProgress: (event) => record("progress", stageMessage(event), event) + }); + }); + return; + } + + if (pathname === "/api/switch") { + const operationStorage = await captureOperationStorage(body, response); + if (!operationStorage) return; + await withOperation("switch", response, async () => { + assertWebOperationStorage(operationStorage.storage, "switch"); + const provider = requireProvider(body.provider); + const keepCount = requireKeepCount(body.keepCount); + const model = requireString(body.model, "model", { optional: true, maxLength: 500 }); + return api.runSwitch({ + ...operationStorage.profile, + storage: operationStorage.storage, + expectedConfigText: operationStorage.configText, + provider, + keepCount, + model, + keepRootModel: Boolean(body.keepRootModel), + onProgress: (event) => record("progress", stageMessage(event), event) + }); + }); + return; + } + + if (pathname === "/api/restore") { + const operationStorage = await captureOperationStorage(body, response); + if (!operationStorage) return; + await withOperation("restore", response, async () => { + assertWebOperationStorage(operationStorage.storage, "restore"); + const backupId = requireString(body.backupId, "backupId", { maxLength: 300 }); + const listed = await api.listBackups(operationStorage.profile.codexHome); + const backup = listed.backups.find((entry) => entry.id === backupId); + if (!backup) { + throw new Error("The selected backup is not a managed backup for this Codex Home."); + } + const restoreConfig = Boolean(body.restoreConfig); + const restoreDatabase = Boolean(body.restoreDatabase); + const restoreSessions = Boolean(body.restoreSessions); + if (!restoreConfig && !restoreDatabase && !restoreSessions) { + throw new Error("Select at least one backup content type to restore."); + } + if (body.allowSqliteHomeRelocation && !operationStorage.profile.sqliteHome) { + throw new Error("SQLite Home relocation requires a storage profile with an explicit SQLite Home target."); + } + return api.runRestore({ + ...operationStorage.profile, + storage: operationStorage.storage, + expectedConfigText: operationStorage.configText, + backupDir: backup.path, + restoreConfig, + restoreDatabase, + restoreSessions, + allowSqliteHomeRelocation: Boolean(body.allowSqliteHomeRelocation) + }); + }); + return; + } + + if (pathname === "/api/prune") { + const storage = captureStorageProfile(body, stateStore, response); + if (!storage) return; + await withOperation("prune backups", response, async () => { + return api.runPruneBackups({ codexHome: storage.codexHome, keepCount: requireKeepCount(body.keepCount, { allowZero: true }) }); + }); + return; + } + + sendError(response, 404, "Unknown API endpoint."); + return; + } + + if (request.method !== "GET" && request.method !== "HEAD") { + sendError(response, 405, "Method not allowed."); + return; + } + await serveStatic(response, pathname, webRoot); + } catch (error) { + sendError(response, 500, error); + } + }); + + return { + server, + internalSecret, + instanceId, + issuePairing, + setBaseUrl(value) { + baseUrl = value; + }, + getActivity() { + return [...activity]; + } + }; +} + +function requestExistingChallenge({ port, instanceId }) { + return new Promise((resolve, reject) => { + const challengeRequest = internalChallengeRequestPayload({ port, instanceId }); + const body = JSON.stringify(challengeRequest); + const request = http.request({ + hostname: "127.0.0.1", + port, + path: "/api/internal/challenge", + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(body) + }, + timeout: 1500 + }, (response) => { + const chunks = []; + response.on("data", (chunk) => chunks.push(chunk)); + response.on("end", () => { + try { + const payload = JSON.parse(Buffer.concat(chunks).toString("utf8")); + if (response.statusCode !== 200 + || payload.protocolVersion !== INTERNAL_PROTOCOL_VERSION + || payload.port !== port + || payload.instanceId !== instanceId + || !validInternalNonce(payload.nonce)) { + reject(new Error("The existing listener is not a compatible Codex Provider Sync Web UI.")); + return; + } + resolve(internalChallengePayload(payload)); + } catch (error) { + reject(error); + } + }); + }); + request.once("timeout", () => request.destroy(new Error("Timed out contacting the existing Web UI."))); + request.once("error", reject); + request.end(body); + }); +} + +function requestExistingPairing({ port, instanceId, internalSecret, resetAccess, challenge }) { + return new Promise((resolve, reject) => { + const signedRequest = internalRequestPayload({ + port, + instanceId, + nonce: challenge.nonce, + resetAccess + }); + const body = JSON.stringify(signedRequest); + const request = http.request({ + hostname: "127.0.0.1", + port, + path: "/api/internal/new-pairing", + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(body), + "X-Codex-Provider-Internal-Proof": internalProof(internalSecret, INTERNAL_REQUEST_DOMAIN, signedRequest) + }, + timeout: 1500 + }, (response) => { + const chunks = []; + response.on("data", (chunk) => chunks.push(chunk)); + response.on("end", () => { + try { + const payload = JSON.parse(Buffer.concat(chunks).toString("utf8")); + if (response.statusCode !== 200 + || payload.protocolVersion !== INTERNAL_PROTOCOL_VERSION + || payload.port !== port + || payload.instanceId !== instanceId + || payload.nonce !== challenge.nonce + || payload.resetAccess !== Boolean(resetAccess) + || typeof payload.pairingToken !== "string" + || !payload.pairingToken + || payload.pairingToken.length > 4096) { + reject(new Error("The existing listener is not a compatible Codex Provider Sync Web UI.")); + return; + } + const signedResponse = internalResponsePayload({ + port: payload.port, + instanceId: payload.instanceId, + nonce: payload.nonce, + resetAccess: payload.resetAccess, + pairingToken: payload.pairingToken + }); + const expectedProof = internalProof(internalSecret, INTERNAL_RESPONSE_DOMAIN, signedResponse); + if (!internalProofsMatch(payload.proof, expectedProof)) { + reject(new Error("The existing listener failed authenticated Web UI pairing.")); + return; + } + resolve(payload.pairingToken); + } catch (error) { + reject(error); + } + }); + }); + request.once("timeout", () => request.destroy(new Error("Timed out contacting the existing Web UI."))); + request.once("error", reject); + request.end(body); + }); +} + +async function readRuntimeDescriptor(runtimeFile) { + try { + const value = JSON.parse(await fs.readFile(runtimeFile, "utf8")); + if (Number.isInteger(value?.port) && value.port > 0 && validSecretToken(value?.internalSecret)) { + return value; + } + } catch { + // A missing, stale, or malformed descriptor is treated as no live instance. + } + return null; +} + +async function removeOwnedRuntimeDescriptor(runtimeFile, internalSecret) { + const current = await readRuntimeDescriptor(runtimeFile); + if (current?.internalSecret === internalSecret) { + await fs.rm(runtimeFile, { force: true }).catch(() => {}); + } +} + +function comparableRuntimePath(value, platform) { + return platform === "win32" ? value.toLowerCase() : value; +} + +function hasRuntimeIdentity(value) { + return typeof value?.codexHome === "string" && typeof value?.sqliteHome === "string"; +} + +function hasSecureRuntimeDescriptor(value) { + return value?.protocolVersion === INTERNAL_PROTOCOL_VERSION + && validSecretToken(value?.instanceId); +} + +function runtimeIdentityMatches(existing, requested, platform) { + return comparableRuntimePath(existing.codexHome, platform) === comparableRuntimePath(requested.codexHome, platform) + && comparableRuntimePath(existing.sqliteHome, platform) === comparableRuntimePath(requested.sqliteHome, platform); +} + +function runtimeIdentityMismatchError(existing, requested) { + return new Error( + `A Web UI instance is already running on port ${existing.port} for Codex Home "${existing.codexHome}" and SQLite Home "${existing.sqliteHome}", ` + + `but this launch resolved Codex Home "${requested.codexHome}" and SQLite Home "${requested.sqliteHome}". ` + + "Close the existing Web UI instance and restart with the requested storage identity." + ); +} + +function legacyRuntimeDescriptorError(existing) { + return new Error( + `A Web UI instance is already running on port ${existing.port}, but its runtime descriptor does not contain the authenticated v2 instance and storage identity. ` + + "Close that Web UI instance and restart it so the secure runtime identity can be recorded." + ); +} + +function isConnectionRefused(error) { + return error?.code === "ECONNREFUSED" || error?.cause?.code === "ECONNREFUSED"; +} + +async function resolveRuntimeIdentity({ codexHome, sqliteHome, environment, platform }) { + let configText = ""; + try { + configText = await readConfigText(path.join(codexHome, "config.toml")); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + const layout = resolveStorageLayout({ codexHome, sqliteHome, configText, env: environment, platform }); + return { codexHome: layout.codexHome, sqliteHome: layout.sqliteHome }; +} + +export async function startWebUi({ + port = DEFAULT_PORT, + openBrowser = true, + resetAccess = false, + codexHome, + sqliteHome, + stateFile, + runtimeFile, + webRoot = WEB_ROOT, + services, + platform = process.platform, + environment = process.env, + openUrl = openLocalUrl +} = {}) { + if (!Number.isInteger(port) || port < 0 || port > 65535) { + throw new Error(`Invalid Web UI port: ${port}.`); + } + await fs.access(path.join(webRoot, "index.html")).catch(() => { + throw new Error(`Web UI build not found at ${webRoot}. Run \"npm run web:build\" first.`); + }); + + const controlCodexHome = path.resolve(codexHome ?? environment.CODEX_HOME ?? defaultCodexHome()); + const resolvedStateFile = path.resolve(stateFile ?? path.join(controlCodexHome, STATE_FILENAME)); + const resolvedRuntimeFile = path.resolve(runtimeFile ?? path.join(controlCodexHome, RUNTIME_FILENAME)); + const runtimeIdentity = await resolveRuntimeIdentity({ codexHome: controlCodexHome, sqliteHome, environment, platform }); + const existing = await readRuntimeDescriptor(resolvedRuntimeFile); + if (existing) { + let challenge = null; + const descriptorInstanceId = hasSecureRuntimeDescriptor(existing) ? existing.instanceId : "legacy"; + try { + challenge = await requestExistingChallenge({ + port: existing.port, + instanceId: descriptorInstanceId + }); + } catch (error) { + if (isConnectionRefused(error)) { + await removeOwnedRuntimeDescriptor(resolvedRuntimeFile, existing.internalSecret); + } else { + throw new Error("The existing listener could not complete secure Web UI authentication. Close it and restart the Web UI.", { cause: error }); + } + } + if (challenge) { + if (!hasSecureRuntimeDescriptor(existing) || !hasRuntimeIdentity(existing)) throw legacyRuntimeDescriptorError(existing); + if (!runtimeIdentityMatches(existing, runtimeIdentity, platform)) { + throw runtimeIdentityMismatchError(existing, runtimeIdentity); + } + let pairingToken; + try { + pairingToken = await requestExistingPairing({ + port: existing.port, + instanceId: existing.instanceId, + internalSecret: existing.internalSecret, + resetAccess, + challenge + }); + } catch (error) { + throw new Error("The existing listener failed authenticated Web UI pairing. Close it and restart the Web UI.", { cause: error }); + } + const url = `http://127.0.0.1:${existing.port}`; + const pairingUrl = `${url}/#pair=${encodeURIComponent(pairingToken)}`; + const browserOpened = openBrowser && canOpenLocalUrl(platform, environment) + ? await openUrl(pairingUrl, platform) + : false; + return { + reused: true, + url, + pairingUrl, + browserOpened, + close: async () => {} + }; + } + } + + const stateStore = new WebUiStateStore({ + filePath: resolvedStateFile, + defaultProfile: { codexHome: controlCodexHome, sqliteHome } + }); + await stateStore.initialize({ resetAccess }); + const internalSecret = randomSecret(); + const instanceId = randomSecret(); + const handle = createWebUiServer({ webRoot, services, stateStore, internalSecret, instanceId, platform, environment }); + try { + await new Promise((resolve, reject) => { + handle.server.once("error", reject); + handle.server.listen(port, "127.0.0.1", resolve); + }); + } catch (error) { + if (error?.code === "EADDRINUSE") { + throw new Error(`Web UI port ${port} is already in use by another program. Stop that program or choose --port .`, { cause: error }); + } + throw error; + } + const address = handle.server.address(); + const actualPort = typeof address === "object" && address ? address.port : port; + const url = `http://127.0.0.1:${actualPort}`; + handle.setBaseUrl(url); + const pairingUrl = `${url}/#pair=${encodeURIComponent(handle.issuePairing())}`; + await fs.mkdir(path.dirname(resolvedRuntimeFile), { recursive: true }); + await fs.writeFile(resolvedRuntimeFile, `${JSON.stringify({ protocolVersion: INTERNAL_PROTOCOL_VERSION, instanceId, port: actualPort, internalSecret, pid: process.pid, ...runtimeIdentity })}\n`, { encoding: "utf8", mode: 0o600 }); + await fs.chmod(resolvedRuntimeFile, 0o600).catch(() => {}); + const browserOpened = openBrowser && canOpenLocalUrl(platform, environment) + ? await openUrl(pairingUrl, platform) + : false; + return { + ...handle, + reused: false, + url, + pairingUrl, + browserOpened, + stateFile: resolvedStateFile, + close: async () => { + await new Promise((resolve, reject) => handle.server.close((error) => error ? reject(error) : resolve())); + await removeOwnedRuntimeDescriptor(resolvedRuntimeFile, internalSecret); + } + }; +} diff --git a/src/web-state.js b/src/web-state.js new file mode 100644 index 0000000..ad96bf5 --- /dev/null +++ b/src/web-state.js @@ -0,0 +1,290 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; + +const STATE_VERSION = 1; +const DEFAULT_PROFILE_ID = "default"; + +function hashSecret(secret) { + return crypto.createHash("sha256").update(String(secret), "utf8").digest("hex"); +} + +function normalizeOptionalPath(value) { + if (value === undefined || value === null || String(value).trim() === "") { + return null; + } + return path.resolve(String(value).trim()); +} + +function normalizeProfile({ id, name, codexHome, sqliteHome }) { + const normalizedId = String(id ?? "").trim(); + const normalizedName = String(name ?? "").trim(); + if (!/^[A-Za-z0-9_.-]{1,80}$/.test(normalizedId)) { + throw new Error("profileId may only contain letters, numbers, dots, underscores, and hyphens."); + } + if (!normalizedName || normalizedName.length > 120) { + throw new Error("Profile name must be between 1 and 120 characters."); + } + if (typeof codexHome !== "string" || !codexHome.trim()) { + throw new Error("Codex Home is required for a storage profile."); + } + if (codexHome.includes("\0") || String(sqliteHome ?? "").includes("\0")) { + throw new Error("Storage paths may not contain NUL characters."); + } + return { + id: normalizedId, + name: normalizedName, + codexHome: path.resolve(codexHome.trim()), + sqliteHome: normalizeOptionalPath(sqliteHome) + }; +} + +function profileRevision(profile) { + const canonical = JSON.stringify({ + id: profile.id, + name: profile.name, + codexHome: profile.codexHome, + sqliteHome: profile.sqliteHome + }); + return crypto.createHash("sha256").update(canonical, "utf8").digest("base64url"); +} + +function publicProfile(profile) { + return { ...profile, revision: profileRevision(profile) }; +} + +export class ProfileRevisionConflictError extends Error { + constructor(code, profile = null) { + const message = code === "PROFILE_REVISION_REQUIRED" + ? "This profile update requires the current profile revision. Refresh and try again." + : "The storage profile changed before the update was saved. Refresh and try again."; + super(message); + this.name = "ProfileRevisionConflictError"; + this.code = code; + this.profile = profile; + } +} + +function assertSaveRevision(currentProfile, expectedRevision) { + const hasExpectedRevision = typeof expectedRevision === "string" && expectedRevision.length > 0; + if (currentProfile) { + if (!hasExpectedRevision) { + throw new ProfileRevisionConflictError("PROFILE_REVISION_REQUIRED", currentProfile); + } + if (expectedRevision !== currentProfile.revision) { + throw new ProfileRevisionConflictError("PROFILE_CHANGED", currentProfile); + } + return; + } + if (hasExpectedRevision) { + throw new ProfileRevisionConflictError("PROFILE_CHANGED", null); + } +} + +async function assertDirectoryWhenPresent(value, label) { + if (!value) { + return; + } + try { + const stats = await fs.stat(value); + if (!stats.isDirectory()) { + throw new Error(`${label} must refer to a directory: ${value}`); + } + } catch (error) { + if (error?.code !== "ENOENT") { + throw error; + } + } +} + +function emptyState(defaultProfile) { + return { + version: STATE_VERSION, + credentialHashes: [], + profiles: [defaultProfile] + }; +} + +export class WebUiStateStore { + constructor({ filePath, defaultProfile, validateDirectory = assertDirectoryWhenPresent }) { + if (!filePath) { + throw new Error("Web UI state file path is required."); + } + this.filePath = path.resolve(filePath); + this.defaultProfile = normalizeProfile({ + id: DEFAULT_PROFILE_ID, + name: "Default", + ...defaultProfile + }); + this.state = emptyState(this.defaultProfile); + this.writeQueue = Promise.resolve(); + this.validateDirectory = validateDirectory; + } + + async initialize({ resetAccess = false } = {}) { + try { + const parsed = JSON.parse(await fs.readFile(this.filePath, "utf8")); + if (parsed?.version === STATE_VERSION && Array.isArray(parsed.profiles) && Array.isArray(parsed.credentialHashes)) { + const namedProfiles = parsed.profiles + .filter((profile) => profile?.id !== DEFAULT_PROFILE_ID) + .map((profile) => normalizeProfile(profile)); + this.state = { + version: STATE_VERSION, + credentialHashes: parsed.credentialHashes.filter((value) => typeof value === "string" && /^[a-f0-9]{64}$/.test(value)), + profiles: [this.defaultProfile, ...namedProfiles] + }; + } + } catch (error) { + if (error?.code !== "ENOENT") { + throw new Error(`Unable to read Web UI state at ${this.filePath}: ${error.message}`, { cause: error }); + } + } + if (resetAccess) { + this.state.credentialHashes = []; + } + await this.persist(); + return this.snapshot(); + } + + snapshot() { + return { + version: this.state.version, + credentialHashes: [...this.state.credentialHashes], + profiles: this.state.profiles.map((profile) => ({ ...profile })) + }; + } + + listProfiles() { + return this.state.profiles.map(publicProfile); + } + + hasProfile(profileId) { + return this.state.profiles.some((profile) => profile.id === profileId); + } + + getProfile(profileId = DEFAULT_PROFILE_ID) { + const profile = this.state.profiles.find((entry) => entry.id === profileId); + if (!profile) { + throw new Error(`Unknown storage profile: ${profileId}`); + } + return publicProfile(profile); + } + + async saveProfile(input, { expectedRevision } = {}) { + const profile = normalizeProfile(input); + if (profile.id === DEFAULT_PROFILE_ID) { + throw new Error("The default storage profile is controlled by Web UI startup flags."); + } + await this.validateDirectory(profile.codexHome, "Codex Home"); + await this.validateDirectory(profile.sqliteHome, "SQLite Home"); + const index = this.state.profiles.findIndex((entry) => entry.id === profile.id); + assertSaveRevision(index >= 0 ? publicProfile(this.state.profiles[index]) : null, expectedRevision); + if (index >= 0) { + this.state.profiles[index] = profile; + } else { + this.state.profiles.push(profile); + } + await this.persist(); + return publicProfile(profile); + } + + async deleteProfile(profileId) { + if (profileId === DEFAULT_PROFILE_ID) { + throw new Error("The default storage profile cannot be deleted."); + } + const before = this.state.profiles.length; + this.state.profiles = this.state.profiles.filter((entry) => entry.id !== profileId); + if (this.state.profiles.length === before) { + throw new Error(`Unknown storage profile: ${profileId}`); + } + await this.persist(); + } + + hasCredential(secret) { + if (typeof secret !== "string" || secret.length < 32) { + return false; + } + const candidate = Buffer.from(hashSecret(secret), "hex"); + return this.state.credentialHashes.some((stored) => { + const expected = Buffer.from(stored, "hex"); + return expected.length === candidate.length && crypto.timingSafeEqual(expected, candidate); + }); + } + + async addCredential(secret) { + const digest = hashSecret(secret); + if (!this.state.credentialHashes.includes(digest)) { + this.state.credentialHashes.push(digest); + await this.persist(); + } + } + + async removeCredential(secret) { + const digest = hashSecret(secret); + this.state.credentialHashes = this.state.credentialHashes.filter((stored) => stored !== digest); + await this.persist(); + } + + async resetCredentials() { + this.state.credentialHashes = []; + await this.persist(); + } + + async persist() { + const serialized = `${JSON.stringify(this.state, null, 2)}\n`; + const target = this.filePath; + this.writeQueue = this.writeQueue.then(async () => { + await fs.mkdir(path.dirname(target), { recursive: true }); + const temporary = `${target}.tmp-${process.pid}-${crypto.randomBytes(6).toString("hex")}`; + await fs.writeFile(temporary, serialized, { encoding: "utf8", mode: 0o600 }); + await fs.rename(temporary, target); + await fs.chmod(target, 0o600).catch(() => {}); + }); + return this.writeQueue; + } +} + +export function createMemoryWebUiState(defaultProfile) { + const normalizedDefault = normalizeProfile({ id: DEFAULT_PROFILE_ID, name: "Default", ...defaultProfile }); + let credentialHashes = []; + let profiles = [normalizedDefault]; + return { + listProfiles: () => profiles.map(publicProfile), + hasProfile: (profileId) => profiles.some((profile) => profile.id === profileId), + getProfile(profileId = DEFAULT_PROFILE_ID) { + const profile = profiles.find((entry) => entry.id === profileId); + if (!profile) throw new Error(`Unknown storage profile: ${profileId}`); + return publicProfile(profile); + }, + async saveProfile(input, { expectedRevision } = {}) { + const profile = normalizeProfile(input); + if (profile.id === DEFAULT_PROFILE_ID) throw new Error("The default storage profile is controlled by Web UI startup flags."); + const index = profiles.findIndex((entry) => entry.id === profile.id); + assertSaveRevision(index >= 0 ? publicProfile(profiles[index]) : null, expectedRevision); + if (index >= 0) profiles[index] = profile; + else profiles.push(profile); + return publicProfile(profile); + }, + async deleteProfile(profileId) { + if (profileId === DEFAULT_PROFILE_ID) throw new Error("The default storage profile cannot be deleted."); + profiles = profiles.filter((entry) => entry.id !== profileId); + }, + hasCredential(secret) { + return credentialHashes.includes(hashSecret(secret)); + }, + async addCredential(secret) { + const digest = hashSecret(secret); + if (!credentialHashes.includes(digest)) credentialHashes.push(digest); + }, + async removeCredential(secret) { + const digest = hashSecret(secret); + credentialHashes = credentialHashes.filter((stored) => stored !== digest); + }, + async resetCredentials() { + credentialHashes = []; + }, + credentialHashes: () => [...credentialHashes] + }; +} + +export { DEFAULT_PROFILE_ID, hashSecret, profileRevision }; diff --git a/test/history-requests.test.js b/test/history-requests.test.js new file mode 100644 index 0000000..dcf1713 --- /dev/null +++ b/test/history-requests.test.js @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createLatestRequestGate, scheduleDebounced } from "../web/src/history-requests.js"; + +test("starting a newer History request aborts the older request and rejects its response", () => { + const gate = createLatestRequestGate(); + const first = gate.begin(); + const second = gate.begin(); + + assert.equal(first.signal.aborted, true); + assert.equal(second.signal.aborted, false); + assert.equal(gate.isLatest(first.sequence), false); + assert.equal(gate.isLatest(second.sequence), true); +}); + +test("History query debounce uses a 300ms delay and can be cancelled", () => { + const calls = []; + const timers = { + setTimeout(callback, delay) { calls.push(["set", delay, callback]); return 17; }, + clearTimeout(id) { calls.push(["clear", id]); } + }; + const cancel = scheduleDebounced(() => {}, 300, timers); + cancel(); + assert.deepEqual(calls.slice(0, 2).map((call) => call.slice(0, 2)), [["set", 300], ["clear", 17]]); +}); diff --git a/test/history.test.js b/test/history.test.js new file mode 100644 index 0000000..85ae837 --- /dev/null +++ b/test/history.test.js @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { getHistorySession, listHistory } from "../src/history.js"; + +async function fixture() { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "codex-history-")); + const file = path.join(home, "sessions", "2026", "08", "04", "rollout-one.jsonl"); + await fs.mkdir(path.dirname(file), { recursive: true }); + const lines = [ + { type: "session_meta", timestamp: "2026-08-04T08:00:00.000Z", payload: { id: "thread-one", title: "测试会话", cwd: "/work/demo", model_provider: "openai", model: "gpt-5" } }, + { type: "event_msg", timestamp: "2026-08-04T08:01:00.000Z", payload: { type: "user_message", message: "请总结这个项目" } }, + { type: "event_msg", timestamp: "2026-08-04T08:02:00.000Z", payload: { type: "assistant_message", message: "这是项目总结。" } }, + { type: "response_item", timestamp: "2026-08-04T08:03:00.000Z", payload: { type: "message", role: "assistant", content: [{ type: "output_text", text: "Agent 的详细回答。" }] } }, + { type: "event_msg", payload: { type: "tool_call", arguments: "secret" } }, + { type: "event_msg", payload: { encrypted_content: "gAAA" } } + ]; + await fs.writeFile(file, lines.map((line) => JSON.stringify(line)).join("\n") + "\n", "utf8"); + return { home, file }; +} + +test("history lists readable sessions and filters message text", async () => { + const { home } = await fixture(); + try { + const result = await listHistory(home, { page: 1, pageSize: 50, query: "总结" }); + assert.equal(result.total, 1); + assert.equal(result.sessions[0].id, "thread-one"); + assert.equal(result.sessions[0].messageCount, 3); + } finally { + await fs.rm(home, { recursive: true, force: true }); + } +}); + +test("history detail returns only safe messages with a limit", async () => { + const { home } = await fixture(); + try { + const result = await getHistorySession(home, "thread-one", { messageLimit: 1 }); + assert.equal(result.returnedMessageCount, 1); + assert.equal(result.truncated, true); + assert.equal(result.messages[0].role, "assistant"); + assert.equal(result.messages[0].text, "Agent 的详细回答。"); + assert.doesNotMatch(JSON.stringify(result), /encrypted_content|tool_call|secret/); + } finally { + await fs.rm(home, { recursive: true, force: true }); + } +}); + +test("history prefers canonical user events over response-item bootstrap and duplicate messages", async () => { + const { home, file } = await fixture(); + try { + const lines = [ + { type: "session_meta", timestamp: "2026-08-04T08:00:00.000Z", payload: { id: "thread-one", cwd: "/work/demo", model_provider: "openai" } }, + { type: "response_item", timestamp: "2026-08-04T08:00:10.000Z", payload: { type: "message", role: "user", content: [{ type: "input_text", text: "internal bootstrap" }] } }, + { type: "response_item", timestamp: "2026-08-04T08:01:00.000Z", payload: { type: "message", role: "user", content: [{ type: "input_text", text: "请检查真实标题" }] } }, + { type: "event_msg", timestamp: "2026-08-04T08:01:00.000Z", payload: { type: "user_message", message: "请检查真实标题" } }, + { type: "response_item", timestamp: "2026-08-04T08:02:00.000Z", payload: { type: "message", role: "assistant", content: [{ type: "output_text", text: "标题已检查。" }] } } + ]; + await fs.writeFile(file, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`, "utf8"); + + const list = await listHistory(home, { page: 1, pageSize: 50 }); + assert.equal(list.sessions[0].title, "请检查真实标题"); + assert.equal(list.sessions[0].firstUserMessage, "请检查真实标题"); + assert.equal(list.sessions[0].messageCount, 2); + + const detail = await getHistorySession(home, "thread-one"); + assert.deepEqual(detail.messages.map(({ role, text }) => ({ role, text })), [ + { role: "user", text: "请检查真实标题" }, + { role: "assistant", text: "标题已检查。" } + ]); + + const legacyLines = [ + lines[0], + { type: "response_item", timestamp: "2026-08-04T08:01:00.000Z", payload: { type: "message", role: "user", content: [{ type: "input_text", text: "旧格式用户消息" }] } }, + lines.at(-1) + ]; + await fs.writeFile(file, `${legacyLines.map((line) => JSON.stringify(line)).join("\n")}\n`, "utf8"); + const legacy = await listHistory(home, { page: 1, pageSize: 50 }); + assert.equal(legacy.sessions[0].firstUserMessage, "旧格式用户消息"); + assert.equal(legacy.sessions[0].messageCount, 2); + } finally { + await fs.rm(home, { recursive: true, force: true }); + } +}); + +test("history keeps the newest session when rollouts share a thread id", async () => { + const { home } = await fixture(); + try { + const archived = path.join(home, "archived_sessions", "2026", "08", "04", "rollout-copy.jsonl"); + await fs.mkdir(path.dirname(archived), { recursive: true }); + await fs.writeFile(archived, `${JSON.stringify({ type: "session_meta", payload: { id: "thread-one", title: "新副本", cwd: "/work/demo", model_provider: "openai" } })}\n`, "utf8"); + const newer = new Date(Date.now() + 1000); + await fs.utimes(archived, newer, newer); + const result = await listHistory(home, { page: 1, pageSize: 50 }); + assert.equal(result.total, 1); + assert.equal(result.sessions[0].title, "新副本"); + } finally { + await fs.rm(home, { recursive: true, force: true }); + } +}); + +test("history ignores rollout files that disappear before their content is read", async () => { + const { home, file } = await fixture(); + try { + await fs.rm(file); + const result = await listHistory(home, { page: 1, pageSize: 50 }); + assert.equal(result.total, 0); + } finally { + await fs.rm(home, { recursive: true, force: true }); + } +}); + +test("history exposes a stable bounded id when a session has no thread id", async () => { + const { home, file } = await fixture(); + try { + await fs.writeFile(file, `${JSON.stringify({ type: "session_meta", payload: { title: "No thread id", cwd: "/work/demo", model_provider: "openai" } })}\n`, "utf8"); + const first = await listHistory(home, { page: 1, pageSize: 50 }); + const second = await listHistory(home, { page: 1, pageSize: 50 }); + assert.equal(first.total, 1); + assert.match(first.sessions[0].id, /^rollout:[A-Za-z0-9_-]{43}$/); + assert.equal(first.sessions[0].id, second.sessions[0].id); + assert.ok(first.sessions[0].id.length <= 300); + assert.equal(first.sessions[0].rolloutPath, path.resolve(file)); + } finally { + await fs.rm(home, { recursive: true, force: true }); + } +}); diff --git a/test/profile-refresh.test.js b/test/profile-refresh.test.js new file mode 100644 index 0000000..2aef3ae --- /dev/null +++ b/test/profile-refresh.test.js @@ -0,0 +1,134 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createProfileRefresh, storagePayload } from "../web/src/profile-refresh.js"; + +function createDeferred() { + let resolve; + let reject; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function createRecordingFetches() { + const calls = []; + const pending = new Map(); + const fetchFor = (kind) => (storage, options = {}) => { + const deferred = createDeferred(); + const key = `${kind}:${storage.profileId}`; + calls.push({ kind, profileId: storage.profileId, signal: options.signal }); + pending.set(key, deferred); + return deferred.promise; + }; + return { + calls, + pending, + fetchStatus: fetchFor("status"), + fetchBackups: fetchFor("backups"), + resolveFor(profileId, { status = {}, backups = {} } = {}) { + pending.get(`status:${profileId}`)?.resolve({ status }); + pending.get(`backups:${profileId}`)?.resolve(backups); + }, + rejectFor(profileId, error) { + pending.get(`status:${profileId}`)?.reject(error); + pending.get(`backups:${profileId}`)?.reject(error); + } + }; +} + +function createUiRecorder() { + const recorder = { + applied: [], + errors: [], + loading: [] + }; + recorder.onResult = (result) => recorder.applied.push(result); + recorder.onError = (error) => recorder.errors.push(error.message); + recorder.onLoading = (value) => recorder.loading.push(value); + return recorder; +} + +test("storagePayload defaults to the default profile", () => { + assert.deepEqual(storagePayload(), { profileId: "default" }); + assert.deepEqual(storagePayload("work"), { profileId: "work" }); +}); + +test("profile refresh race: A starts first, B finishes first, A finishes last — UI stays on B", async () => { + const fetches = createRecordingFetches(); + const ui = createUiRecorder(); + const refresh = createProfileRefresh({ fetchStatus: fetches.fetchStatus, fetchBackups: fetches.fetchBackups }); + + // The user is on profile A and a refresh is in flight. + const refreshA = refresh({ profileId: "a", onLoading: ui.onLoading, onResult: ui.onResult, onError: ui.onError }); + // The user switches to profile B before A completes. + const refreshB = refresh({ profileId: "b", onLoading: ui.onLoading, onResult: ui.onResult, onError: ui.onError }); + + // Starting B aborts A's underlying requests. + const requestA = fetches.calls.find((call) => call.profileId === "a"); + assert.equal(requestA.signal.aborted, true); + + // B finishes first and is applied. + fetches.resolveFor("b", { status: { currentProvider: "provider-b" }, backups: { backups: ["b-backup"] } }); + assert.equal(await refreshB, true); + assert.deepEqual(ui.applied.map((entry) => entry.profileId), ["b"]); + assert.deepEqual(ui.loading, [true, true, false]); + + // A finishes last; its results, loading transitions, and errors must be discarded. + fetches.resolveFor("a", { status: { currentProvider: "provider-a" }, backups: { backups: ["a-backup"] } }); + assert.equal(await refreshA, false); + assert.deepEqual(ui.applied.map((entry) => entry.profileId), ["b"]); + assert.deepEqual(ui.applied[0].status, { currentProvider: "provider-b" }); + assert.deepEqual(ui.errors, []); + assert.deepEqual(ui.loading, [true, true, false]); +}); + +test("profile refresh race: a stale request failing late never surfaces an error", async () => { + const fetches = createRecordingFetches(); + const ui = createUiRecorder(); + const refresh = createProfileRefresh({ fetchStatus: fetches.fetchStatus, fetchBackups: fetches.fetchBackups }); + + const refreshA = refresh({ profileId: "a", onLoading: ui.onLoading, onResult: ui.onResult, onError: ui.onError }); + const refreshB = refresh({ profileId: "b", onLoading: ui.onLoading, onResult: ui.onResult, onError: ui.onError }); + + fetches.resolveFor("b", { status: { currentProvider: "provider-b" } }); + assert.equal(await refreshB, true); + + fetches.rejectFor("a", new Error("profile A backend exploded")); + assert.equal(await refreshA, false); + assert.deepEqual(ui.errors, []); + assert.deepEqual(ui.applied.map((entry) => entry.profileId), ["b"]); +}); + +test("profile refresh surfaces errors from the latest request only", async () => { + const fetches = createRecordingFetches(); + const ui = createUiRecorder(); + const refresh = createProfileRefresh({ fetchStatus: fetches.fetchStatus, fetchBackups: fetches.fetchBackups }); + + const refreshA = refresh({ profileId: "a", onLoading: ui.onLoading, onResult: ui.onResult, onError: ui.onError }); + fetches.rejectFor("a", new Error("profile A is unreachable")); + assert.equal(await refreshA, false); + assert.deepEqual(ui.errors, ["profile A is unreachable"]); + assert.deepEqual(ui.applied, []); + assert.deepEqual(ui.loading, [true, false]); +}); + +test("profile refresh quiet mode leaves the loading indicator untouched unless it is the latest", async () => { + const fetches = createRecordingFetches(); + const ui = createUiRecorder(); + const refresh = createProfileRefresh({ fetchStatus: fetches.fetchStatus, fetchBackups: fetches.fetchBackups }); + + const refreshA = refresh({ profileId: "a", onLoading: ui.onLoading, onResult: ui.onResult, onError: ui.onError }); + const quietB = refresh({ profileId: "b", showLoading: false, onLoading: ui.onLoading, onResult: ui.onResult, onError: ui.onError }); + + fetches.resolveFor("b", { status: { currentProvider: "provider-b" } }); + assert.equal(await quietB, true); + // The quiet refresh never raised the indicator but still settles it as the latest request. + assert.deepEqual(ui.loading, [true, false]); + + fetches.resolveFor("a", { status: { currentProvider: "provider-a" } }); + assert.equal(await refreshA, false); + assert.deepEqual(ui.loading, [true, false]); +}); diff --git a/test/release-metadata.test.js b/test/release-metadata.test.js index a70ad5d..cfbf62b 100644 --- a/test/release-metadata.test.js +++ b/test/release-metadata.test.js @@ -53,10 +53,10 @@ test("reads the current repository Chinese release metadata", () => { const testDirectory = path.dirname(fileURLToPath(import.meta.url)); const rootDir = path.resolve(testDirectory, ".."); - const result = readReleaseMetadata({ rootDir, tag: "v0.4.0" }); + const result = readReleaseMetadata({ rootDir, tag: "v0.5.0" }); - assert.equal(result.title, "v0.4.0 - 更安全的事务化同步与自动化支持"); - assert.equal(result.relativeBodyPath, "docs/release-notes/v0.4.0-zh.md"); + assert.equal(result.title, "v0.5.0 - Web UI 自动配对与本地访问边界加固"); + assert.equal(result.relativeBodyPath, "docs/release-notes/v0.5.0-zh.md"); }); test("reads one validated release title and body path", () => diff --git a/test/sync-service.test.js b/test/sync-service.test.js index b64d1c9..b64794a 100644 --- a/test/sync-service.test.js +++ b/test/sync-service.test.js @@ -16,7 +16,11 @@ import { import { getStatus, renderStatus, runRestore, runSwitch, runSync } from "../src/service.js"; import { DB_FILE_BASENAME, DEFAULT_BACKUP_RETENTION_COUNT, SQLITE_DIR_BASENAME } from "../src/constants.js"; import { getUnsupportedNodeVersionMessage } from "../src/node-version.js"; -import { applySessionChanges, collectSessionChanges } from "../src/session-files.js"; +import { + applySessionChanges, + collectSessionChanges, + createWindowsExclusiveRewriteWorker +} from "../src/session-files.js"; import { openDatabase } from "../src/sqlite.js"; import { TransactionJournal, @@ -488,6 +492,37 @@ test("unfinished journal blocks writes until the bound backup is restored", asyn assert.equal(result.targetProvider, "openai"); }); +test("runRestore reports a warning instead of failing when the inventory refresh fails", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-a.jsonl"); + await writeRollout(sessionPath, "thread-a", "apigather"); + await writeStateDb(codexHome, [ + { id: "thread-a", model_provider: "apigather", archived: false } + ]); + + const synced = await runSync({ codexHome }); + const backupDir = synced.backupDir; + + // Fail only the write the post-restore inventory refresh performs. The + // injected seam is deterministic regardless of the user the suite runs as, + // which file permission bits are not: root bypasses them entirely. + const result = await runRestore({ + backupDir, + codexHome, + faultInjector: ({ point }) => { + if (point === "before_atomic_replace") { + throw new Error("injected inventory write failure"); + } + } + }); + + // The restore itself is authoritative and must still be reported as done. + assert.equal(result.targetProvider, "openai"); + assert.match(result.backupInventoryWarning, /Backup inventory refresh failed/); + assert.match(await fs.readFile(sessionPath, "utf8"), /"model_provider":"apigather"/); +}); + test("crash recovery restores actually mutated rollout and database from a pending journal", async () => { const { codexHome } = await makeTempCodexHome(); await writeConfig(codexHome, 'model_provider = "openai"'); @@ -720,6 +755,60 @@ test("repeated sync is idempotent for rollout and SQLite state", async () => { assert.deepEqual(await findPendingTransactions(codexHome), []); }); +test("runSync leaves the backup manifest and metadata payload unchanged after creation", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-immutable-backup.jsonl"); + await writeRollout(sessionPath, "thread-immutable-backup", "apigather"); + await writeStateDb(codexHome, [{ id: "thread-immutable-backup", model_provider: "apigather" }]); + + let backupDir = null; + let manifestBefore = null; + let metadataBefore = null; + const result = await runSync({ + codexHome, + onProgress(event) { + if (event.stage === "create_backup" && event.status === "complete") { + backupDir = event.backupDir; + } + }, + async faultInjector({ point }) { + if (point === "before_rollout_apply" && manifestBefore === null) { + manifestBefore = await fs.readFile(path.join(backupDir, "session-meta-backup.json")); + metadataBefore = await fs.readFile(path.join(backupDir, "metadata.json")); + } + } + }); + + assert.equal(result.changedSessionFiles, 1); + assert.ok(manifestBefore); + assert.ok(metadataBefore); + assert.deepEqual(await fs.readFile(path.join(backupDir, "session-meta-backup.json")), manifestBefore); + const metadataBeforeValue = JSON.parse(metadataBefore.toString("utf8")); + const metadataAfterValue = JSON.parse( + await fs.readFile(path.join(backupDir, "metadata.json"), "utf8") + ); + const { + sizeBytes: sizeBytesBefore, + fileCount: fileCountBefore, + ...metadataPayloadBefore + } = metadataBeforeValue; + const { + sizeBytes: sizeBytesAfter, + fileCount: fileCountAfter, + ...metadataPayloadAfter + } = metadataAfterValue; + assert.deepEqual(metadataPayloadAfter, metadataPayloadBefore); + assert.ok(sizeBytesAfter > sizeBytesBefore); + assert.equal(fileCountAfter, fileCountBefore + 1); + assert.deepEqual( + { sizeBytes: sizeBytesAfter, fileCount: fileCountAfter }, + await getDirectoryInventory(backupDir) + ); + const manifest = JSON.parse(manifestBefore.toString("utf8")); + assert.equal(manifest.appliedPaths, null); +}); + test("failure after model mutation but before journal applied restores full rollout bytes", async () => { const { codexHome } = await makeTempCodexHome(); await writeConfig(codexHome, 'model_provider = "openai"\nmodel = "gpt-new"'); @@ -754,6 +843,56 @@ test("failure after model mutation but before journal applied restores full roll assert.deepEqual(await findPendingTransactions(codexHome), []); }); +test("lost Windows worker acknowledgement after File.Replace restores the applying target", { + skip: process.platform !== "win32" +}, async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const configPath = path.join(codexHome, "config.toml"); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-lost-worker-ack.jsonl"); + await writeRollout(sessionPath, "thread-lost-worker-ack", "apigather"); + const before = await fs.readFile(sessionPath); + const { changes } = await collectSessionChanges(codexHome, "openai"); + const backupDir = await createBackup({ + codexHome, + targetProvider: "openai", + sessionChanges: changes, + configPath + }); + const journal = await TransactionJournal.create(backupDir, { + codexHome, + targetProvider: "openai", + potentialTargets: [sessionPath] + }); + + await assert.rejects( + applySessionChanges(changes, { + onBeforeApply: (change) => journal.applying("rollout", change.path), + windowsRewriteWorkerFactory: async () => { + const actual = await createWindowsExclusiveRewriteWorker(); + return { + async rewrite(change, options) { + await actual.rewrite(change, options); + throw new Error("injected lost worker acknowledgement"); + }, + close: () => actual.close() + }; + } + }), + /injected lost worker acknowledgement/ + ); + + assert.notDeepEqual(await fs.readFile(sessionPath), before); + const pending = await readTransactionJournal(journal.filePath); + assert.equal(pending.state, "applying"); + await restoreBackup(backupDir, codexHome, { + restoreConfig: false, + restoreDatabase: false, + restoreSessions: true + }); + assert.deepEqual(await fs.readFile(sessionPath), before); +}); + test("immutable full manifest restores a later applying target after abrupt exit", async () => { const { codexHome } = await makeTempCodexHome(); await writeConfig(codexHome, 'model_provider = "openai"'); @@ -797,6 +936,7 @@ test("immutable full manifest restores a later applying target after abrupt exit const fullBackupDir = path.join(backupRoot(codexHome), backupDir); const manifest = JSON.parse(await fs.readFile(path.join(fullBackupDir, "session-meta-backup.json"), "utf8")); assert.equal(manifest.files.length, 2); + assert.equal(manifest.appliedPaths, null); await runRestore({ codexHome, @@ -809,6 +949,51 @@ test("immutable full manifest restores a later applying target after abrupt exit assert.deepEqual(await findPendingTransactions(codexHome), []); }); +test("abrupt parent exit after the first applied rollout never mutates the next rollout", { + skip: process.platform !== "win32" +}, async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const firstPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-parent-exit-a.jsonl"); + const secondPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-parent-exit-b.jsonl"); + await writeRollout(firstPath, "thread-parent-exit-a", "apigather"); + await writeRollout(secondPath, "thread-parent-exit-b", "apigather"); + const before = await Promise.all([fs.readFile(firstPath), fs.readFile(secondPath)]); + + const childScript = ` + import { runSync } from "./src/service.js"; + await runSync({ + codexHome: ${JSON.stringify(codexHome)}, + provider: "openai", + faultInjector: ({ point, appliedCount }) => { + if (point === "after_rollout_apply" && appliedCount === 1) { + process.exit(24); + } + } + }); + `; + const child = spawn(process.execPath, ["--input-type=module", "-e", childScript], { + cwd: path.resolve(".") + }); + const exitCode = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", resolve); + }); + + assert.equal(exitCode, 24); + assert.notDeepEqual(await fs.readFile(firstPath), before[0]); + assert.deepEqual(await fs.readFile(secondPath), before[1]); + const [backupDir] = await fs.readdir(backupRoot(codexHome)); + await runRestore({ + codexHome, + backupDir: path.join(backupRoot(codexHome), backupDir), + restoreConfig: false, + restoreDatabase: false + }); + assert.deepEqual(await fs.readFile(firstPath), before[0]); + assert.deepEqual(await fs.readFile(secondPath), before[1]); +}); + test("foreign operationId and missing-final-newline journals stay pending until explicit restore", async (t) => { for (const corruption of ["foreign-operation", "missing-final-newline", "commit-after-recovery-required"]) { await t.test(corruption, async () => { @@ -1282,6 +1467,24 @@ async function writeBackup(codexHome, directoryName, files) { return totalBytes; } +async function getDirectoryInventory(directoryPath) { + const entries = await fs.readdir(directoryPath, { withFileTypes: true }); + let sizeBytes = 0; + let fileCount = 0; + for (const entry of entries) { + const fullPath = path.join(directoryPath, entry.name); + if (entry.isDirectory()) { + const child = await getDirectoryInventory(fullPath); + sizeBytes += child.sizeBytes; + fileCount += child.fileCount; + } else if (entry.isFile()) { + sizeBytes += (await fs.stat(fullPath)).size; + fileCount += 1; + } + } + return { sizeBytes, fileCount }; +} + async function writeConfig(codexHome, modelProviderLine = "") { const config = `${modelProviderLine}${modelProviderLine ? "\n" : ""}sandbox_mode = "danger-full-access"\n\n[model_providers.apigather]\nbase_url = "https://example.com"\n`; await fs.writeFile(path.join(codexHome, "config.toml"), config, "utf8"); @@ -1503,6 +1706,13 @@ test("runSync rewrites rollout files and sqlite, then restore reverts both", asy assert.equal(backupMetadata.version, 2); assert.equal(backupMetadata.sqliteHome, path.join(codexHome, SQLITE_DIR_BASENAME)); assert.deepEqual(backupMetadata.sqliteDbFiles, [DB_FILE_BASENAME]); + assert.ok(Number.isSafeInteger(backupMetadata.sizeBytes)); + assert.ok(backupMetadata.sizeBytes > 0); + assert.ok(Number.isSafeInteger(backupMetadata.fileCount)); + assert.ok(backupMetadata.fileCount > 0); + const backupInventory = await getDirectoryInventory(syncResult.backupDir); + assert.equal(backupMetadata.sizeBytes, backupInventory.sizeBytes); + assert.equal(backupMetadata.fileCount, backupInventory.fileCount); assert.deepEqual( backupMetadata.dbFiles.map((fileName) => fileName.replaceAll("\\", "/")), ["sqlite/state_5.sqlite"] @@ -3475,6 +3685,33 @@ test("pruneBackups removes the oldest backup directories", async () => { await fs.access(path.join(backupRoot(codexHome), "20260321T000000000Z")); }); +test("backup summary and prune use cached inventory with legacy fallback", async () => { + const { codexHome } = await makeTempCodexHome(); + const cachedDir = path.join(backupRoot(codexHome), "20260319T000000000Z"); + const legacyDir = path.join(backupRoot(codexHome), "20260320T000000000Z"); + await fs.mkdir(cachedDir, { recursive: true }); + await fs.mkdir(legacyDir, { recursive: true }); + await fs.writeFile(path.join(cachedDir, "metadata.json"), JSON.stringify({ + namespace: "provider-sync", + sizeBytes: 123, + fileCount: 1 + }), "utf8"); + await fs.writeFile(path.join(cachedDir, "added-later.bin"), "x".repeat(4096), "utf8"); + await fs.writeFile(path.join(legacyDir, "metadata.json"), JSON.stringify({ + namespace: "provider-sync" + }), "utf8"); + await fs.writeFile(path.join(legacyDir, "payload.bin"), "legacy", "utf8"); + const legacyBytes = (await fs.stat(path.join(legacyDir, "metadata.json"))).size + + (await fs.stat(path.join(legacyDir, "payload.bin"))).size; + + const summary = await getBackupSummary(codexHome); + assert.deepEqual(summary, { count: 2, totalBytes: 123 + legacyBytes }); + + const pruned = await pruneBackups(codexHome, 1); + assert.equal(pruned.deletedCount, 1); + assert.equal(pruned.freedBytes, 123); +}); + test("pruneBackups ignores directories without managed backup metadata", async () => { const { codexHome } = await makeTempCodexHome(); await writeBackup(codexHome, "20260320T000000000Z", [ @@ -3535,6 +3772,55 @@ test("runSync auto-prunes backups to the default retention count", async () => { assert.equal(result.autoPruneWarning, null); }); +test("runSync succeeds with a warning and still prunes when the inventory refresh fails after commit", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-a.jsonl"); + await writeRollout(sessionPath, "thread-a", "apigather"); + await writeStateDb(codexHome, [ + { id: "thread-a", model_provider: "apigather", archived: false } + ]); + + for (let index = 0; index < DEFAULT_BACKUP_RETENTION_COUNT; index += 1) { + await writeBackup(codexHome, `20240101T0000${String(index).padStart(2, "0")}000Z`, [ + ["note.txt", `backup-${index}`] + ]); + } + + const result = await runSync({ + codexHome, + // Break the inventory refresh that runs right after the journal commit. The + // transaction is durable by then, so the sync must report success with a + // warning instead of failing and skipping the prune below. + async faultInjector({ point }) { + if (point !== "before_transaction_commit") { + return; + } + const dirs = await fs.readdir(backupRoot(codexHome)); + for (const dir of dirs) { + const candidate = path.join(backupRoot(codexHome), dir); + try { + await fs.access(path.join(candidate, "transaction-journal.jsonl")); + } catch { + continue; + } + // Keep the namespace so the directory is still a managed backup for the + // prune pass, but make the version unreadable so only the inventory + // refresh fails. + const metadataPath = path.join(candidate, "metadata.json"); + const metadata = JSON.parse(await fs.readFile(metadataPath, "utf8")); + await fs.writeFile(metadataPath, JSON.stringify({ ...metadata, version: 99 }, null, 2)); + } + } + }); + + assert.equal(result.autoPruneResult.deletedCount, 1); + assert.match(result.autoPruneWarning, /Backup inventory refresh failed/); + assert.match(await fs.readFile(sessionPath, "utf8"), /"model_provider":"openai"/); + assert.equal(await readProvider(codexHome, "thread-a"), "openai"); + assert.deepEqual(await findPendingTransactions(codexHome), []); +}); + test("runSync uses a custom automatic backup retention count", async () => { const { codexHome } = await makeTempCodexHome(); await writeConfig(codexHome, 'model_provider = "openai"'); @@ -3566,11 +3852,13 @@ test("cli rejects non-integer keep values", async () => { assert.match(result.stderr, /Invalid --keep value: 1\.5/); }); -test("node version guard allows Node 16 and rejects older releases", () => { - assert.equal(getUnsupportedNodeVersionMessage("16.0.0"), null); +test("node version guard requires Node 16.20.2 or newer", () => { + assert.equal(getUnsupportedNodeVersionMessage("16.20.2"), null); + assert.equal(getUnsupportedNodeVersionMessage("18.0.0"), null); + assert.match(getUnsupportedNodeVersionMessage("16.20.1"), /requires Node\.js 16\.20\.2/); assert.match( getUnsupportedNodeVersionMessage("14.21.3"), - /requires Node\.js 16\+/ + /requires Node\.js 16\.20\.2/ ); }); @@ -3759,6 +4047,45 @@ test("invalid journal tail with no validated target restores the full session ma assert.deepEqual(await findPendingTransactions(codexHome), []); }); +test("missing or empty journal restores the full immutable session manifest", async (t) => { + for (const journalState of ["missing", "empty"]) { + await t.test(journalState, async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const configPath = path.join(codexHome, "config.toml"); + const sessionPath = path.join( + codexHome, + "sessions", + "2026", + "03", + "19", + `rollout-${journalState}-journal.jsonl` + ); + await writeRollout(sessionPath, `thread-${journalState}-journal`, "apigather"); + const original = await fs.readFile(sessionPath, "utf8"); + const { changes } = await collectSessionChanges(codexHome, "openai"); + const backupDir = await createBackup({ + codexHome, + targetProvider: "openai", + sessionChanges: changes, + configPath + }); + if (journalState === "empty") { + await fs.writeFile(path.join(backupDir, "transaction-journal.jsonl"), "", "utf8"); + } + await writeRollout(sessionPath, `thread-${journalState}-journal`, "openai"); + + await restoreBackup(backupDir, codexHome, { + restoreConfig: false, + restoreDatabase: false, + restoreSessions: true + }); + + assert.equal(await fs.readFile(sessionPath, "utf8"), original); + }); + } +}); + test("automatic rollback does not report success unless the rolledBack terminal re-reads valid", async () => { const { codexHome } = await makeTempCodexHome(); await writeConfig(codexHome, 'model_provider = "openai"'); diff --git a/test/web-server.test.js b/test/web-server.test.js new file mode 100644 index 0000000..922bdf3 --- /dev/null +++ b/test/web-server.test.js @@ -0,0 +1,1086 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { createWebUiServer, startWebUi } from "../src/web-server.js"; +import { createMemoryWebUiState, WebUiStateStore } from "../src/web-state.js"; + +function request({ origin, pathname = "/", method = "GET", body, headers = {}, hostHeader }) { + return new Promise((resolve, reject) => { + const target = new URL(origin); + const serialized = body === undefined ? null : JSON.stringify(body); + const client = http.request({ + hostname: target.hostname, + port: target.port, + path: pathname, + method, + headers: { + ...(hostHeader ? { Host: hostHeader } : {}), + ...(serialized ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(serialized) } : {}), + ...headers + } + }, (response) => { + const chunks = []; + response.on("data", (chunk) => chunks.push(chunk)); + response.on("end", () => { + const text = Buffer.concat(chunks).toString("utf8"); + let payload = null; + try { payload = JSON.parse(text); } catch {} + resolve({ status: response.statusCode, text, payload, headers: response.headers }); + }); + }); + client.once("error", reject); + if (serialized) client.write(serialized); + client.end(); + }); +} + +async function startFixture(services = {}, options = {}) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "codex-provider-sync-web-")); + await fs.writeFile( + path.join(root, "index.html"), + 'fixture', + "utf8" + ); + const stateStore = options.stateStore ?? createMemoryWebUiState({ + codexHome: root, + ...(options.defaultSqliteHome ? { sqliteHome: options.defaultSqliteHome } : {}) + }); + const handle = createWebUiServer({ + webRoot: root, + services, + stateStore, + now: options.now, + pairingTtlMs: options.pairingTtlMs, + platform: options.platform, + environment: options.environment + }); + await new Promise((resolve, reject) => { + handle.server.once("error", reject); + handle.server.listen(0, "127.0.0.1", resolve); + }); + const address = handle.server.address(); + const origin = `http://127.0.0.1:${address.port}`; + handle.setBaseUrl(origin); + return { + ...handle, + stateStore, + root, + origin, + async pair(pairingToken = handle.issuePairing(), { originHeader = origin, hostHeader } = {}) { + const response = await request({ + origin, + pathname: "/api/pair", + method: "POST", + headers: { Origin: originHeader, "X-Codex-Provider-Pairing": pairingToken }, + hostHeader + }); + return { response, credential: response.payload?.deviceCredential }; + }, + close: async () => { + await new Promise((resolve, reject) => handle.server.close((error) => error ? reject(error) : resolve())); + await fs.rm(root, { recursive: true, force: true }); + } + }; +} + +async function api(handle, pathname, body = {}, credential, { originHeader = handle.origin, hostHeader } = {}) { + const profileRevisionEndpoints = new Set(["/api/sync", "/api/switch", "/api/restore", "/api/prune"]); + const storageRevisionEndpoints = new Set(["/api/sync", "/api/switch", "/api/restore"]); + const profile = profileRevisionEndpoints.has(pathname) && body.profileId && !Object.hasOwn(body, "profileRevision") + ? handle.stateStore.getProfile(body.profileId) + : null; + let preparedBody = profile ? { ...body, profileRevision: profile.revision } : body; + if (storageRevisionEndpoints.has(pathname) && !Object.hasOwn(preparedBody, "storageRevision")) { + const status = await request({ + origin: handle.origin, + pathname: "/api/status", + method: "POST", + body: { profileId: preparedBody.profileId ?? "default" }, + hostHeader, + headers: { + Origin: originHeader, + "X-Codex-Provider-Device": credential ?? "" + } + }); + if (status.payload?.status?.storageRevision) { + preparedBody = { ...preparedBody, storageRevision: status.payload.status.storageRevision }; + } + } + return request({ + origin: handle.origin, + pathname, + method: "POST", + body: preparedBody, + hostHeader, + headers: { + Origin: originHeader, + "X-Codex-Provider-Device": credential ?? "" + } + }); +} + +function statusFixture(overrides = {}) { + return { + codexHome: "/tmp/.codex", + sqliteHome: "/tmp/.codex/sqlite", + sqliteHomeSource: "default", + sqliteAccess: { supported: true, reason: null, message: null }, + checkedStateDbPaths: ["/tmp/.codex/sqlite/state_5.sqlite"], + currentProvider: "openai", + currentProviderImplicit: false, + configuredProviders: ["openai", "relay"], + rolloutCounts: { sessions: { relay: 2, openai: 3 }, archived_sessions: { openai: 1 } }, + lockedRolloutFiles: [], + encryptedContentCounts: { sessions: {}, archived_sessions: {} }, + encryptedContentWarning: null, + sqliteCounts: { sessions: { openai: 3, relay: 2 }, archived_sessions: { openai: 1 } }, + stateDbLocation: { path: "/tmp/.codex/sqlite/state_5.sqlite", source: "sqlite-dir" }, + sqliteRepairStats: { userEventRowsNeedingRepair: 0, cwdRowsNeedingRepair: 0 }, + projectThreadVisibility: [], + backupRoot: "/tmp/.codex/backups_state/provider-sync", + backupSummary: { count: 0, totalBytes: 0 }, + ...overrides + }; +} + +test("status alignment follows the current provider without requiring equal inventory counts", async () => { + let currentStatus = statusFixture({ + currentProvider: "dal", + configuredProviders: ["dal", "openai"], + rolloutCounts: { sessions: { dal: 949 }, archived_sessions: {} }, + sqliteCounts: { sessions: { dal: 948 }, archived_sessions: {} } + }); + const handle = await startFixture({ getStatus: async () => currentStatus }); + try { + const paired = await handle.pair(); + const readAlignment = async () => { + const response = await api(handle, "/api/status", { profileId: "default" }, paired.credential); + assert.equal(response.status, 200); + return response.payload.status.alignment; + }; + + assert.equal((await readAlignment()).aligned, true); + + currentStatus = { + ...currentStatus, + sqliteCounts: { sessions: { dal: 947, openai: 1 }, archived_sessions: {} } + }; + assert.equal((await readAlignment()).aligned, false); + + currentStatus = { + ...currentStatus, + rolloutCounts: { sessions: { openai: 948 }, archived_sessions: {} }, + sqliteCounts: { sessions: { openai: 948 }, archived_sessions: {} } + }; + assert.equal((await readAlignment()).aligned, false); + + currentStatus = { + ...currentStatus, + rolloutCounts: { sessions: { dal: 949 }, archived_sessions: {} }, + sqliteCounts: { sessions: { dal: 948 }, archived_sessions: {} }, + lockedRolloutFiles: ["C:\\locked-rollout.jsonl"] + }; + assert.equal((await readAlignment()).aligned, false); + } finally { + await handle.close(); + } +}); + +test("anonymous HTML contains no API or pairing credential and write APIs require pairing", async () => { + const handle = await startFixture({ getStatus: async () => statusFixture() }); + try { + const pairingToken = handle.issuePairing(); + const page = await request({ origin: handle.origin }); + assert.equal(page.status, 200); + assert.doesNotMatch(page.text, new RegExp(pairingToken)); + assert.doesNotMatch(page.text, /apiToken|X-Codex-Provider-Token/); + assert.doesNotMatch(page.text, /__CODEX_PROVIDER_SYNC_BOOTSTRAP__/); + assert.match(page.headers["content-security-policy"], /script-src 'self';/); + + const denied = await api(handle, "/api/status", { profileId: "default" }, "wrong-token"); + assert.equal(denied.status, 403); + assert.equal(denied.payload.code, "PAIRING_REQUIRED"); + + const paired = await handle.pair(pairingToken); + assert.equal(paired.response.status, 200); + assert.ok(paired.credential.length >= 32); + const replay = await handle.pair(pairingToken); + assert.equal(replay.response.status, 403); + + const allowed = await api(handle, "/api/status", { profileId: "default" }, paired.credential); + assert.equal(allowed.status, 200); + } finally { + await handle.close(); + } +}); + +test("pairing credentials expire and pairing material is never recorded as activity", async () => { + let currentTime = 1000; + const handle = await startFixture({}, { now: () => currentTime, pairingTtlMs: 300 }); + try { + const pairingToken = handle.issuePairing(); + currentTime += 301; + const expired = await handle.pair(pairingToken); + assert.equal(expired.response.status, 403); + assert.doesNotMatch(JSON.stringify(handle.getActivity()), new RegExp(pairingToken)); + } finally { + await handle.close(); + } +}); + +test("internal pairing requires and consumes a server-issued authenticated challenge", async () => { + const handle = await startFixture(); + try { + const port = Number(new URL(handle.origin).port); + const challenge = await request({ + origin: handle.origin, + pathname: "/api/internal/challenge", + method: "POST", + body: { protocolVersion: 2, port, instanceId: handle.instanceId } + }); + assert.equal(challenge.status, 200); + const signed = { + protocolVersion: 2, + port, + instanceId: handle.instanceId, + nonce: challenge.payload.nonce, + resetAccess: false + }; + const proof = crypto.createHmac("sha256", handle.internalSecret) + .update(`codex-provider-sync:web-ui:internal-pairing:v2:request\n${JSON.stringify(signed)}`, "utf8") + .digest("base64url"); + const first = await request({ + origin: handle.origin, + pathname: "/api/internal/new-pairing", + method: "POST", + body: signed, + headers: { "X-Codex-Provider-Internal-Proof": proof } + }); + assert.equal(first.status, 200); + assert.equal(first.payload.nonce, challenge.payload.nonce); + assert.equal(first.payload.resetAccess, false); + + const replay = await request({ + origin: handle.origin, + pathname: "/api/internal/new-pairing", + method: "POST", + body: signed, + headers: { "X-Codex-Provider-Internal-Proof": proof } + }); + assert.equal(replay.status, 403); + assert.equal(replay.payload.code, "INTERNAL_CHALLENGE_REQUIRED"); + } finally { + await handle.close(); + } +}); + +test("Origin validation uses the actual loopback Host and supports forwarded ports", async () => { + const handle = await startFixture({ getStatus: async () => statusFixture() }); + try { + const { credential } = await handle.pair(); + const invalid = await api(handle, "/api/status", { profileId: "default" }, credential, { originHeader: "http://evil.example" }); + assert.equal(invalid.status, 403); + assert.equal(invalid.payload.code, "INVALID_ORIGIN"); + + const forwardedHost = "localhost:19091"; + const forwarded = await api(handle, "/api/status", { profileId: "default" }, credential, { + originHeader: `http://${forwardedHost}`, + hostHeader: forwardedHost + }); + assert.equal(forwarded.status, 200); + } finally { + await handle.close(); + } +}); + +test("server-managed profiles reject per-operation paths and resolve profileId", async () => { + const calls = []; + const handle = await startFixture({ getStatus: async (storage) => { calls.push(storage); return statusFixture({ codexHome: storage.codexHome }); } }); + try { + const { credential } = await handle.pair(); + const rawPath = await api(handle, "/api/status", { codexHome: "/tmp/other" }, credential); + assert.equal(rawPath.status, 500); + assert.match(rawPath.payload.error, /server-managed profileId/); + + const workCodexHome = path.join(handle.root, "work-codex"); + const workSqliteHome = path.join(handle.root, "work-sqlite"); + await fs.mkdir(workCodexHome); + await fs.mkdir(workSqliteHome); + const saved = await api(handle, "/api/profiles/save", { + profileId: "work", + name: "Work", + codexHome: workCodexHome, + sqliteHome: workSqliteHome + }, credential); + assert.equal(saved.status, 200); + + const response = await api(handle, "/api/status", { profileId: "work" }, credential); + assert.equal(response.status, 200); + assert.equal(response.payload.status.pathComparisonCaseInsensitive, process.platform === "win32"); + assert.equal(calls.at(-1).codexHome, path.resolve(workCodexHome)); + assert.equal(calls.at(-1).sqliteHome, path.resolve(workSqliteHome)); + } finally { + await handle.close(); + } +}); + +test("Web UI sync delegates only server-resolved storage to the shared service", async () => { + const calls = []; + const handle = await startFixture({ + readConfigText: async () => 'model_provider = "openai"\nmodel = "gpt-5"\n', + readRootModelFromConfigText: () => "gpt-5", + runSync: async (options) => { calls.push(options); options.onProgress({ stage: "create_backup", status: "start" }); return { targetProvider: options.provider, backupDir: "/tmp/backup" }; } + }); + try { + await fs.writeFile(path.join(handle.root, "state_5.sqlite"), "not-a-real-db"); + const { credential } = await handle.pair(); + const invalid = await api(handle, "/api/sync", { profileId: "default", provider: "bad provider", keepCount: 5 }, credential); + assert.equal(invalid.status, 400); + const response = await api(handle, "/api/sync", { profileId: "default", provider: "openai", keepCount: 5 }, credential); + assert.equal(response.status, 200); + assert.equal(calls.length, 1); + assert.equal(calls[0].codexHome, path.resolve(handle.root)); + assert.equal(calls[0].model, "gpt-5"); + assert.equal(calls[0].storage.stateDbLocation.source, "legacy-root"); + assert.equal(calls[0].storage.stateDbLocation.path, path.join(handle.root, "state_5.sqlite")); + assert.ok(handle.getActivity().some((entry) => entry.message === "Creating backup")); + } finally { + await handle.close(); + } +}); + +test("Web UI rejects an operation when config changes the effective SQLite target after confirmation", async () => { + let configText = 'model_provider = "openai"\nsqlite_home = "sqlite-a"\n'; + const syncCalls = []; + const handle = await startFixture({ + readConfigText: async () => configText, + readRootModelFromConfigText: () => null, + getStatus: async ({ storage }) => statusFixture({ + codexHome: storage.codexHome, + sqliteHome: storage.sqliteHome, + sqliteHomeSource: storage.sqliteHomeSource, + sqliteAccess: storage.sqliteAccess, + stateDbLocation: storage.stateDbLocation, + checkedStateDbPaths: storage.stateDbCandidates.map((candidate) => candidate.path) + }), + runSync: async (options) => { syncCalls.push(options); return {}; } + }); + try { + const { credential } = await handle.pair(); + const profile = handle.stateStore.getProfile("default"); + const confirmed = await api(handle, "/api/status", { profileId: "default" }, credential); + assert.equal(confirmed.status, 200); + + const missing = await request({ + origin: handle.origin, + pathname: "/api/sync", + method: "POST", + body: { profileId: "default", profileRevision: profile.revision, provider: "openai", keepCount: 5 }, + headers: { Origin: handle.origin, "X-Codex-Provider-Device": credential } + }); + assert.equal(missing.status, 409); + assert.equal(missing.payload.code, "STORAGE_REVISION_REQUIRED"); + + configText = 'model_provider = "openai"\nsqlite_home = "sqlite-b"\n'; + const changed = await request({ + origin: handle.origin, + pathname: "/api/sync", + method: "POST", + body: { + profileId: "default", + profileRevision: profile.revision, + storageRevision: confirmed.payload.status.storageRevision, + provider: "openai", + keepCount: 5 + }, + headers: { Origin: handle.origin, "X-Codex-Provider-Device": credential } + }); + assert.equal(changed.status, 409); + assert.equal(changed.payload.code, "STORAGE_CHANGED"); + assert.equal(syncCalls.length, 0); + + const refreshed = await api(handle, "/api/status", { profileId: "default" }, credential); + const accepted = await request({ + origin: handle.origin, + pathname: "/api/sync", + method: "POST", + body: { + profileId: "default", + profileRevision: profile.revision, + storageRevision: refreshed.payload.status.storageRevision, + provider: "openai", + keepCount: 5 + }, + headers: { Origin: handle.origin, "X-Codex-Provider-Device": credential } + }); + assert.equal(accepted.status, 200); + assert.equal(syncCalls.length, 1); + assert.equal(syncCalls[0].storage.sqliteHome, path.resolve("sqlite-b")); + assert.equal(syncCalls[0].storage.sqliteHomeSource, "config"); + assert.equal(syncCalls[0].expectedConfigText, configText); + } finally { + await handle.close(); + } +}); + +test("Web UI binds confirmed operations to config contents even when storage is unchanged", async () => { + let configText = 'model_provider = "openai"\nmodel = "gpt-5"\n'; + const syncCalls = []; + const handle = await startFixture({ + readConfigText: async () => configText, + readRootModelFromConfigText: (text) => /^model = "([^"]+)"$/m.exec(text)?.[1] ?? null, + getStatus: async ({ storage }) => statusFixture({ + codexHome: storage.codexHome, + sqliteHome: storage.sqliteHome, + sqliteHomeSource: storage.sqliteHomeSource, + sqliteAccess: storage.sqliteAccess, + stateDbLocation: storage.stateDbLocation, + checkedStateDbPaths: storage.stateDbCandidates.map((candidate) => candidate.path) + }), + runSync: async (options) => { syncCalls.push(options); return {}; } + }); + try { + const { credential } = await handle.pair(); + const profile = handle.stateStore.getProfile("default"); + const confirmed = await api(handle, "/api/status", { profileId: "default" }, credential); + assert.equal(confirmed.status, 200); + + configText = 'model_provider = "openai"\nmodel = "gpt-5.2"\n'; + const changed = await request({ + origin: handle.origin, + pathname: "/api/sync", + method: "POST", + body: { + profileId: "default", + profileRevision: profile.revision, + storageRevision: confirmed.payload.status.storageRevision, + provider: "openai", + keepCount: 5 + }, + headers: { Origin: handle.origin, "X-Codex-Provider-Device": credential } + }); + assert.equal(changed.status, 409); + assert.equal(changed.payload.code, "STORAGE_CHANGED"); + assert.match(changed.payload.error, /configuration or effective SQLite storage changed/); + assert.equal(syncCalls.length, 0); + + const refreshed = await api(handle, "/api/status", { profileId: "default" }, credential); + assert.notEqual(refreshed.payload.status.storageRevision, confirmed.payload.status.storageRevision); + const accepted = await request({ + origin: handle.origin, + pathname: "/api/sync", + method: "POST", + body: { + profileId: "default", + profileRevision: profile.revision, + storageRevision: refreshed.payload.status.storageRevision, + provider: "openai", + keepCount: 5 + }, + headers: { Origin: handle.origin, "X-Codex-Provider-Device": credential } + }); + assert.equal(accepted.status, 200); + assert.equal(syncCalls.length, 1); + assert.equal(syncCalls[0].expectedConfigText, configText); + assert.equal(syncCalls[0].model, "gpt-5.2"); + } finally { + await handle.close(); + } +}); + +test("Web UI restore only accepts managed backups for the selected profile", async () => { + let restored = false; + const handle = await startFixture({ + listBackups: async () => ({ backupRoot: "/tmp/.codex/backups_state/provider-sync", backups: [{ id: "known", path: "/tmp/.codex/backups_state/provider-sync/known", metadata: {} }] }), + runRestore: async () => { restored = true; return { targetProvider: "openai" }; } + }); + try { + const { credential } = await handle.pair(); + const response = await api(handle, "/api/restore", { profileId: "default", backupId: "../../outside", restoreDatabase: true, restoreSessions: true }, credential); + assert.equal(response.status, 400); + assert.equal(restored, false); + } finally { + await handle.close(); + } +}); + +test("Web UI history endpoints delegate through the selected profile", async () => { + const calls = []; + const handle = await startFixture({ + listHistory: async (codexHome, options) => { calls.push(["list", codexHome, options]); return { page: 1, pageSize: 50, total: 1, hasNextPage: false, sessions: [{ id: "thread", title: "safe" }] }; }, + getHistorySession: async (codexHome, sessionId) => { calls.push(["detail", codexHome, sessionId]); return { session: { id: sessionId }, messages: [], truncated: false, returnedMessageCount: 0 }; } + }); + try { + const { credential } = await handle.pair(); + assert.equal((await api(handle, "/api/history", { profileId: "default", query: "safe" }, credential)).status, 200); + assert.equal((await api(handle, "/api/history/session", { profileId: "default", sessionId: "thread" }, credential)).status, 200); + assert.deepEqual(calls.map((call) => call[0]), ["list", "detail"]); + } finally { + await handle.close(); + } +}); + +test("Web UI opens a no-thread-id history session from a rollout path longer than the API id limit", async (t) => { + const handle = await startFixture(); + try { + const deepSegments = Array.from({ length: 7 }, (_, index) => `segment-${index}-${"x".repeat(32)}`); + const rolloutPath = path.join(handle.root, "sessions", ...deepSegments, "rollout-no-thread-id.jsonl"); + assert.ok(path.resolve(rolloutPath).length > 300); + try { + await fs.mkdir(path.dirname(rolloutPath), { recursive: true }); + await fs.writeFile(rolloutPath, [ + { type: "session_meta", timestamp: "2026-08-04T08:00:00.000Z", payload: { title: "Long fallback", cwd: "/work/long", model_provider: "openai" } }, + { type: "event_msg", timestamp: "2026-08-04T08:01:00.000Z", payload: { type: "user_message", message: "Open this session" } } + ].map((line) => JSON.stringify(line)).join("\n") + "\n", "utf8"); + } catch (error) { + if (process.platform === "win32" && ["ENAMETOOLONG", "ENOENT"].includes(error?.code)) { + t.skip("Windows long-path support is unavailable on this host."); + return; + } + throw error; + } + + const { credential } = await handle.pair(); + const listed = await api(handle, "/api/history", { profileId: "default" }, credential); + assert.equal(listed.status, 200); + assert.equal(listed.payload.history.total, 1); + const [session] = listed.payload.history.sessions; + assert.match(session.id, /^rollout:[A-Za-z0-9_-]{43}$/); + assert.equal(session.rolloutPath, path.resolve(rolloutPath)); + + const detail = await api(handle, "/api/history/session", { + profileId: "default", + sessionId: session.id + }, credential); + assert.equal(detail.status, 200); + assert.equal(detail.payload.history.session.id, session.id); + assert.equal(detail.payload.history.messages[0].text, "Open this session"); + } finally { + await handle.close(); + } +}); + +test("device credentials persist only as hashes and reset invalidates them", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "codex-provider-sync-state-")); + const filePath = path.join(root, "web-state.json"); + const credential = "device-secret-that-must-not-be-persisted-verbatim"; + try { + const first = new WebUiStateStore({ filePath, defaultProfile: { codexHome: root } }); + await first.initialize(); + await first.addCredential(credential); + const serialized = await fs.readFile(filePath, "utf8"); + assert.doesNotMatch(serialized, new RegExp(credential)); + + const restarted = new WebUiStateStore({ filePath, defaultProfile: { codexHome: root } }); + await restarted.initialize(); + assert.equal(restarted.hasCredential(credential), true); + await restarted.resetCredentials(); + assert.equal(restarted.hasCredential(credential), false); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); + +test("startWebUi listens only on IPv4 loopback and reuses its existing instance", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "codex-provider-sync-start-")); + const webRoot = path.join(root, "web"); + const stateFile = path.join(root, "state.json"); + const runtimeFile = path.join(root, "runtime.json"); + await fs.mkdir(webRoot); + await fs.writeFile(path.join(webRoot, "index.html"), "fixture"); + let first; + try { + first = await startWebUi({ port: 0, openBrowser: false, codexHome: root, stateFile, runtimeFile, webRoot }); + assert.equal(first.server.address().address, "127.0.0.1"); + const initialToken = new URL(first.pairingUrl).hash.slice("#pair=".length); + const paired = await request({ + origin: first.url, + pathname: "/api/pair", + method: "POST", + headers: { Origin: first.url, "X-Codex-Provider-Pairing": initialToken } + }); + const originalCredential = paired.payload.deviceCredential; + const second = await startWebUi({ port: 0, openBrowser: false, resetAccess: true, codexHome: root, stateFile, runtimeFile, webRoot }); + assert.equal(second.reused, true); + assert.equal(second.url, first.url); + assert.match(second.pairingUrl, /#pair=/); + const invalidated = await request({ + origin: first.url, + pathname: "/api/status", + method: "POST", + body: { profileId: "default" }, + headers: { Origin: first.url, "X-Codex-Provider-Device": originalCredential } + }); + assert.equal(invalidated.status, 403); + } finally { + await first?.close(); + await fs.rm(root, { recursive: true, force: true }); + } +}); + +test("startWebUi never discloses its runtime secret and preserves unverifiable descriptors", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "codex-provider-sync-runtime-auth-")); + const webRoot = path.join(root, "web"); + const stateFile = path.join(root, "state.json"); + const runtimeFile = path.join(root, "runtime.json"); + const internalSecret = "S".repeat(43); + const instanceId = "I".repeat(43); + const captured = []; + let fakePort = null; + let rejectChallenge = false; + await fs.mkdir(webRoot); + await fs.writeFile(path.join(webRoot, "index.html"), "fixture"); + const fake = http.createServer(async (incoming, response) => { + const chunks = []; + for await (const chunk of incoming) chunks.push(chunk); + const text = Buffer.concat(chunks).toString("utf8"); + captured.push({ headers: incoming.headers, text }); + if (rejectChallenge) { + const payload = JSON.stringify({ error: "not found" }); + response.writeHead(404, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) }); + response.end(payload); + return; + } + const body = JSON.parse(text || "{}"); + const payload = JSON.stringify(incoming.url === "/api/internal/challenge" + ? { + protocolVersion: 2, + port: fakePort, + instanceId, + nonce: "N".repeat(43) + } + : { + protocolVersion: 2, + port: fakePort, + instanceId, + nonce: body.nonce, + resetAccess: body.resetAccess, + pairingToken: "attacker-controlled-token", + proof: "A".repeat(43) + }); + response.writeHead(200, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) }); + response.end(payload); + }); + try { + await new Promise((resolve, reject) => { + fake.once("error", reject); + fake.listen(0, "127.0.0.1", resolve); + }); + fakePort = fake.address().port; + await fs.writeFile(runtimeFile, `${JSON.stringify({ + protocolVersion: 2, + instanceId, + port: fakePort, + internalSecret, + codexHome: path.resolve(root), + sqliteHome: path.join(path.resolve(root), "sqlite") + })}\n`); + + await assert.rejects( + startWebUi({ port: 0, openBrowser: false, codexHome: root, stateFile, runtimeFile, webRoot }), + /authenticated Web UI pairing/ + ); + assert.equal(captured.length, 2); + assert.ok(captured.every((entry) => entry.headers["x-codex-provider-internal"] === undefined)); + assert.doesNotMatch(JSON.stringify(captured), new RegExp(internalSecret)); + assert.equal(JSON.parse(await fs.readFile(runtimeFile, "utf8")).internalSecret, internalSecret); + + rejectChallenge = true; + await assert.rejects( + startWebUi({ port: 0, openBrowser: false, codexHome: root, stateFile, runtimeFile, webRoot }), + /could not complete secure Web UI authentication/ + ); + assert.doesNotMatch(JSON.stringify(captured), new RegExp(internalSecret)); + assert.equal(JSON.parse(await fs.readFile(runtimeFile, "utf8")).internalSecret, internalSecret); + } finally { + await new Promise((resolve) => fake.close(resolve)); + await fs.rm(root, { recursive: true, force: true }); + } +}); + +test("startWebUi refuses to reuse an instance launched with a different SQLite home", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "codex-provider-sync-sqlite-home-")); + const webRoot = path.join(root, "web"); + const stateFile = path.join(root, "state.json"); + const runtimeFile = path.join(root, "runtime.json"); + const homeA = path.join(root, "sqlite-a"); + const homeB = path.join(root, "sqlite-b"); + await fs.mkdir(webRoot); + await fs.writeFile(path.join(webRoot, "index.html"), "fixture"); + let first; + try { + first = await startWebUi({ port: 0, openBrowser: false, codexHome: root, sqliteHome: homeA, stateFile, runtimeFile, webRoot }); + + // Launching with a different SQLite home must not silently reuse instance A. + await assert.rejects( + startWebUi({ port: 0, openBrowser: false, codexHome: root, sqliteHome: homeB, stateFile, runtimeFile, webRoot }), + /resolved Codex Home/ + ); + + // The rejection must leave the original instance and its runtime descriptor intact. + const health = await request({ origin: first.url, pathname: "/api/health" }); + assert.equal(health.status, 200); + assert.equal(JSON.parse(await fs.readFile(runtimeFile, "utf8")).port, new URL(first.url).port * 1); + + // Relaunching with the same normalized SQLite home still reuses the instance. + const relaunched = await startWebUi({ + port: 0, + openBrowser: false, + codexHome: root, + sqliteHome: path.join(homeA, "."), + stateFile, + runtimeFile, + webRoot + }); + assert.equal(relaunched.reused, true); + assert.equal(relaunched.url, first.url); + } finally { + await first?.close(); + await fs.rm(root, { recursive: true, force: true }); + } +}); + +test("startWebUi reports occupied ports clearly and handles unavailable or headless browser openers", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "codex-provider-sync-port-")); + const webRoot = path.join(root, "web"); + await fs.mkdir(webRoot); + await fs.writeFile(path.join(webRoot, "index.html"), "fixture"); + const occupied = http.createServer(); + await new Promise((resolve, reject) => { + occupied.once("error", reject); + occupied.listen(0, "127.0.0.1", resolve); + }); + let handle; + try { + const occupiedPort = occupied.address().port; + await assert.rejects( + startWebUi({ + port: occupiedPort, + openBrowser: false, + codexHome: root, + stateFile: path.join(root, "occupied-state.json"), + runtimeFile: path.join(root, "occupied-runtime.json"), + webRoot + }), + /port .* already in use by another program/ + ); + handle = await startWebUi({ + port: 0, + openBrowser: true, + platform: "linux", + environment: { DISPLAY: ":0" }, + openUrl: async () => false, + codexHome: root, + stateFile: path.join(root, "headless-state.json"), + runtimeFile: path.join(root, "headless-runtime.json"), + webRoot + }); + assert.equal(handle.browserOpened, false); + assert.match(handle.pairingUrl, /#pair=/); + await handle.close(); + handle = null; + + let openAttempts = 0; + handle = await startWebUi({ + port: 0, + openBrowser: true, + platform: "linux", + environment: {}, + openUrl: async () => { + openAttempts += 1; + return true; + }, + codexHome: root, + stateFile: path.join(root, "no-display-state.json"), + runtimeFile: path.join(root, "no-display-runtime.json"), + webRoot + }); + assert.equal(openAttempts, 0); + assert.equal(handle.browserOpened, false); + assert.match(handle.pairingUrl, /#pair=/); + } finally { + await handle?.close(); + await new Promise((resolve) => occupied.close(resolve)); + await fs.rm(root, { recursive: true, force: true }); + } +}); + +test("Web UI operations require a current profile revision and preserve a captured profile snapshot", async () => { + let syncCalls = 0; + const handle = await startFixture({ runSync: async () => { syncCalls += 1; return {}; } }); + try { + const first = await handle.pair(); + const second = await handle.pair(); + await api(handle, "/api/profiles/save", { + profileId: "work", + name: "Work", + codexHome: "/tmp/work-before" + }, first.credential); + const stale = handle.stateStore.getProfile("work"); + await api(handle, "/api/profiles/save", { + profileId: "work", + name: "Work updated", + codexHome: "/tmp/work-after", + profileRevision: stale.revision + }, second.credential); + + const changed = await request({ + origin: handle.origin, + pathname: "/api/sync", + method: "POST", + body: { profileId: "work", profileRevision: stale.revision, provider: "openai", keepCount: 5 }, + headers: { Origin: handle.origin, "X-Codex-Provider-Device": first.credential } + }); + assert.equal(changed.status, 409); + assert.equal(changed.payload.code, "PROFILE_CHANGED"); + assert.equal(changed.payload.profile.codexHome, path.resolve("/tmp/work-after")); + assert.equal(syncCalls, 0); + + const required = await request({ + origin: handle.origin, + pathname: "/api/sync", + method: "POST", + body: { profileId: "work", provider: "openai", keepCount: 5 }, + headers: { Origin: handle.origin, "X-Codex-Provider-Device": first.credential } + }); + assert.equal(required.status, 409); + assert.equal(required.payload.code, "PROFILE_REVISION_REQUIRED"); + assert.equal(syncCalls, 0); + } finally { + await handle.close(); + } +}); + +test("Web UI profile save and delete reject missing or stale revisions without changing the profile", async () => { + const handle = await startFixture(); + try { + const { credential } = await handle.pair(); + await api(handle, "/api/profiles/save", { + profileId: "work", + name: "Work", + codexHome: "/tmp/work-before" + }, credential); + const initial = handle.stateStore.getProfile("work"); + + const missingSaveRevision = await api(handle, "/api/profiles/save", { + profileId: "work", + name: "Should not overwrite", + codexHome: "/tmp/work-missing" + }, credential); + assert.equal(missingSaveRevision.status, 409); + assert.equal(missingSaveRevision.payload.code, "PROFILE_REVISION_REQUIRED"); + assert.equal(handle.stateStore.getProfile("work").codexHome, path.resolve("/tmp/work-before")); + + await api(handle, "/api/profiles/save", { + profileId: "work", + name: "Work updated", + codexHome: "/tmp/work-after", + profileRevision: initial.revision + }, credential); + const missingDeleteRevision = await api(handle, "/api/profiles/delete", { + profileId: "work" + }, credential); + assert.equal(missingDeleteRevision.status, 409); + assert.equal(missingDeleteRevision.payload.code, "PROFILE_REVISION_REQUIRED"); + assert.equal(handle.stateStore.hasProfile("work"), true); + + const staleDelete = await api(handle, "/api/profiles/delete", { + profileId: "work", + profileRevision: initial.revision + }, credential); + assert.equal(staleDelete.status, 409); + assert.equal(staleDelete.payload.code, "PROFILE_CHANGED"); + assert.equal(handle.stateStore.hasProfile("work"), true); + + const current = handle.stateStore.getProfile("work"); + const deleted = await api(handle, "/api/profiles/delete", { + profileId: "work", + profileRevision: current.revision + }, credential); + assert.equal(deleted.status, 200); + assert.equal(handle.stateStore.hasProfile("work"), false); + } finally { + await handle.close(); + } +}); + +test("Web UI state profile saves apply revision checks after asynchronous validation", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "codex-provider-sync-profile-cas-")); + let gateActive = false; + let arrivals = 0; + let releaseGate = () => {}; + let gate = Promise.resolve(); + const resetGate = () => { + arrivals = 0; + gate = new Promise((resolve) => { releaseGate = resolve; }); + }; + const store = new WebUiStateStore({ + filePath: path.join(root, "state.json"), + defaultProfile: { codexHome: root }, + validateDirectory: async (_value, label) => { + if (!gateActive || label !== "Codex Home") return; + arrivals += 1; + if (arrivals === 2) releaseGate(); + await gate; + } + }); + try { + await store.initialize(); + await store.saveProfile({ id: "work", name: "Initial", codexHome: root }); + const revision = store.getProfile("work").revision; + + resetGate(); + gateActive = true; + const updates = await Promise.allSettled([ + store.saveProfile({ id: "work", name: "First", codexHome: root }, { expectedRevision: revision }), + store.saveProfile({ id: "work", name: "Second", codexHome: root }, { expectedRevision: revision }) + ]); + assert.equal(updates.filter((result) => result.status === "fulfilled").length, 1); + const rejectedUpdate = updates.find((result) => result.status === "rejected"); + assert.equal(rejectedUpdate.reason.code, "PROFILE_CHANGED"); + assert.ok(["First", "Second"].includes(store.getProfile("work").name)); + + resetGate(); + const creates = await Promise.allSettled([ + store.saveProfile({ id: "new", name: "First create", codexHome: root }), + store.saveProfile({ id: "new", name: "Second create", codexHome: root }) + ]); + assert.equal(creates.filter((result) => result.status === "fulfilled").length, 1); + const rejectedCreate = creates.find((result) => result.status === "rejected"); + assert.equal(rejectedCreate.reason.code, "PROFILE_REVISION_REQUIRED"); + } finally { + gateActive = false; + releaseGate(); + await fs.rm(root, { recursive: true, force: true }); + } +}); + +test("Web UI marks skipped locked rollout files as a partial operation outcome", async () => { + const handle = await startFixture({ + readConfigText: async () => 'model = "gpt-5"\n', + readRootModelFromConfigText: () => "gpt-5", + runSync: async () => ({ skippedLockedRolloutFiles: ["rollout-active.jsonl"] }) + }); + try { + const { credential } = await handle.pair(); + const response = await api(handle, "/api/sync", { profileId: "default", provider: "openai", keepCount: 5 }, credential); + assert.equal(response.status, 200); + assert.equal(response.payload.result.outcome, "partial"); + } finally { + await handle.close(); + } +}); + +test("Web UI restore requires an explicit SQLite Home for relocation and rejects WSL UNC storage", async () => { + let restoreCalls = 0; + const backups = { backupRoot: "/tmp/.codex/backups_state/provider-sync", backups: [{ id: "known", path: "/tmp/.codex/backups_state/provider-sync/known", metadata: {} }] }; + const handle = await startFixture({ listBackups: async () => backups, runRestore: async () => { restoreCalls += 1; return {}; } }); + try { + const { credential } = await handle.pair(); + const relocation = await api(handle, "/api/restore", { + profileId: "default", + backupId: "known", + restoreDatabase: true, + allowSqliteHomeRelocation: true + }, credential); + assert.equal(relocation.status, 400); + assert.match(relocation.payload.error, /explicit SQLite Home target/); + assert.equal(restoreCalls, 0); + } finally { + await handle.close(); + } + + const rawWslUnc = "\\\\wsl.localhost\\Ubuntu\\home\\user\\.codex\\sqlite"; + const wslCodexHome = await fs.mkdtemp(path.join(os.tmpdir(), "codex-provider-sync-wsl-unc-")); + const baseWslStore = createMemoryWebUiState({ codexHome: wslCodexHome }); + const wslStore = { + ...baseWslStore, + getProfile(profileId) { + return { ...baseWslStore.getProfile(profileId), sqliteHome: rawWslUnc }; + } + }; + assert.equal(wslStore.getProfile("default").sqliteHome, rawWslUnc); + + const wslHandle = await startFixture( + { runSync: async () => { restoreCalls += 1; return {}; } }, + { platform: "win32", stateStore: wslStore } + ); + try { + const { credential } = await wslHandle.pair(); + const rejected = await api(wslHandle, "/api/sync", { profileId: "default", provider: "openai", keepCount: 5 }, credential); + assert.equal(rejected.status, 400); + assert.match(rejected.payload.error, /Windows cannot safely access SQLite through the WSL UNC path/); + assert.equal(restoreCalls, 0); + } finally { + await wslHandle.close(); + await fs.rm(wslCodexHome, { recursive: true, force: true }); + } +}); + +test("startWebUi reuses only a matching effective storage identity and replaces dead descriptors", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "codex-provider-sync-runtime-identity-")); + const webRoot = path.join(root, "web"); + const stateFile = path.join(root, "state.json"); + const runtimeFile = path.join(root, "runtime.json"); + const homeA = path.join(root, "sqlite-a"); + const homeB = path.join(root, "sqlite-b"); + await fs.mkdir(webRoot); + await fs.writeFile(path.join(webRoot, "index.html"), "fixture"); + let first; + try { + first = await startWebUi({ port: 0, openBrowser: false, codexHome: root, stateFile, runtimeFile, webRoot, environment: { CODEX_SQLITE_HOME: homeA }, platform: "linux" }); + const descriptor = JSON.parse(await fs.readFile(runtimeFile, "utf8")); + assert.equal(descriptor.codexHome, path.resolve(root)); + assert.equal(descriptor.sqliteHome, path.resolve(homeA)); + + const reused = await startWebUi({ port: 0, openBrowser: false, codexHome: root, stateFile, runtimeFile, webRoot, environment: { CODEX_SQLITE_HOME: homeA }, platform: "linux" }); + assert.equal(reused.reused, true); + await assert.rejects( + startWebUi({ port: 0, openBrowser: false, codexHome: root, stateFile, runtimeFile, webRoot, environment: { CODEX_SQLITE_HOME: homeB }, platform: "linux" }), + /resolved Codex Home/ + ); + await first.close(); + first = null; + + await fs.writeFile(runtimeFile, `${JSON.stringify({ + protocolVersion: 2, + instanceId: "I".repeat(43), + port: 9, + internalSecret: "S".repeat(43), + codexHome: path.resolve(root), + sqliteHome: path.resolve(homeA) + })}\n`); + first = await startWebUi({ port: 0, openBrowser: false, codexHome: root, stateFile, runtimeFile, webRoot, environment: { CODEX_SQLITE_HOME: homeA }, platform: "linux" }); + assert.notEqual(JSON.parse(await fs.readFile(runtimeFile, "utf8")).port, 9); + } finally { + await first?.close(); + await fs.rm(root, { recursive: true, force: true }); + } +}); + +test("runtime identity ignores path case only for win32", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "codex-provider-sync-runtime-case-")); + const webRoot = path.join(root, "web"); + const stateFile = path.join(root, "state.json"); + const runtimeFile = path.join(root, "runtime.json"); + await fs.mkdir(webRoot); + await fs.writeFile(path.join(webRoot, "index.html"), "fixture"); + let first; + try { + first = await startWebUi({ port: 0, openBrowser: false, codexHome: root, sqliteHome: path.join(root, "Sqlite"), stateFile, runtimeFile, webRoot, platform: "win32" }); + const win32Reuse = await startWebUi({ port: 0, openBrowser: false, codexHome: root, sqliteHome: path.join(root, "sqlite"), stateFile, runtimeFile, webRoot, platform: "win32" }); + assert.equal(win32Reuse.reused, true); + await assert.rejects( + startWebUi({ port: 0, openBrowser: false, codexHome: root, sqliteHome: path.join(root, "sqlite"), stateFile, runtimeFile, webRoot, platform: "linux" }), + /resolved Codex Home/ + ); + } finally { + await first?.close(); + await fs.rm(root, { recursive: true, force: true }); + } +}); diff --git a/test/windows-rewrite-worker.test.js b/test/windows-rewrite-worker.test.js new file mode 100644 index 0000000..80a6397 --- /dev/null +++ b/test/windows-rewrite-worker.test.js @@ -0,0 +1,262 @@ +import { EventEmitter } from "node:events"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { PassThrough } from "node:stream"; +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + applySessionChanges, + createWindowsExclusiveRewriteWorker +} from "../src/session-files.js"; + +function createFakeSpawn({ ready = { protocolVersion: 1, type: "ready" }, respond }) { + let spawnCount = 0; + const spawnImpl = () => { + spawnCount += 1; + const child = new EventEmitter(); + child.pid = 4242; + child.stdin = new PassThrough(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + let input = ""; + let exited = false; + + function exit(code, signal = null) { + if (exited) { + return; + } + exited = true; + child.stdout.end(); + child.stderr.end(); + queueMicrotask(() => child.emit("exit", code, signal)); + } + + child.kill = () => { + exit(1, "SIGTERM"); + return true; + }; + child.stdin.setEncoding("utf8"); + child.stdin.on("data", (chunk) => { + input += chunk; + while (input.includes("\n")) { + const newline = input.indexOf("\n"); + const line = input.slice(0, newline); + input = input.slice(newline + 1); + if (!line) { + continue; + } + const request = JSON.parse(line); + const response = respond(request); + if (response && typeof response === "object" && Object.hasOwn(response, "exitCode")) { + exit(response.exitCode, response.signal ?? null); + continue; + } + child.stdout.write( + typeof response === "string" ? `${response}\n` : `${JSON.stringify(response)}\n`, + "utf8" + ); + } + }); + child.stdin.on("finish", () => exit(0)); + queueMicrotask(() => child.stdout.write(`${JSON.stringify(ready)}\n`, "utf8")); + return child; + }; + return { spawnImpl, getSpawnCount: () => spawnCount }; +} + +test("Windows rewrite worker reuses one process and preserves the closed result set", async () => { + const expectedResults = ["APPLIED", "APPLIED_IN_PLACE", "SKIP_BUSY", "SKIP_CHANGED"]; + const fake = createFakeSpawn({ + respond(request) { + return { + protocolVersion: 1, + type: "result", + id: request.id, + path: request.path, + result: expectedResults[request.id - 1] + }; + } + }); + const worker = await createWindowsExclusiveRewriteWorker({ spawnImpl: fake.spawnImpl }); + const results = []; + try { + for (let index = 0; index < expectedResults.length; index += 1) { + results.push(await worker.rewrite( + { path: path.resolve(`rollout-${index}.jsonl`) }, + { requireOriginalMatch: true } + )); + } + } finally { + await worker.close(); + } + + assert.equal(fake.getSpawnCount(), 1); + assert.deepEqual(results, expectedResults); +}); + +test("Windows rewrite worker rejects mismatched and malformed protocol responses", async (t) => { + await t.test("invalid ready message", async () => { + const fake = createFakeSpawn({ + ready: { protocolVersion: 1, type: "unexpected" }, + respond: () => assert.fail("invalid ready must stop before requests") + }); + await assert.rejects( + createWindowsExclusiveRewriteWorker({ spawnImpl: fake.spawnImpl }), + /Unexpected Windows rewrite worker ready message/ + ); + }); + + await t.test("mismatched id", async () => { + const fake = createFakeSpawn({ + respond(request) { + return { + protocolVersion: 1, + type: "result", + id: request.id + 1, + path: request.path, + result: "APPLIED" + }; + } + }); + const worker = await createWindowsExclusiveRewriteWorker({ spawnImpl: fake.spawnImpl }); + await assert.rejects( + worker.rewrite({ path: path.resolve("rollout-id.jsonl") }, { requireOriginalMatch: true }), + /Unexpected Windows rewrite worker response/ + ); + }); + + await t.test("malformed JSON", async () => { + const fake = createFakeSpawn({ respond: () => "not-json" }); + const worker = await createWindowsExclusiveRewriteWorker({ spawnImpl: fake.spawnImpl }); + await assert.rejects( + worker.rewrite({ path: path.resolve("rollout-json.jsonl") }, { requireOriginalMatch: true }), + /malformed JSON/ + ); + }); + + await t.test("worker exits before acknowledgement", async () => { + const fake = createFakeSpawn({ respond: () => ({ exitCode: 9 }) }); + const worker = await createWindowsExclusiveRewriteWorker({ spawnImpl: fake.spawnImpl }); + await assert.rejects( + worker.rewrite({ path: path.resolve("rollout-exit.jsonl") }, { requireOriginalMatch: true }), + /closed stdout unexpectedly/ + ); + }); + + await t.test("worker returns an error protocol message", async () => { + const fake = createFakeSpawn({ + respond(request) { + return { + protocolVersion: 1, + type: "error", + id: request.id, + path: request.path, + message: "injected worker error" + }; + } + }); + const worker = await createWindowsExclusiveRewriteWorker({ spawnImpl: fake.spawnImpl }); + await assert.rejects( + worker.rewrite({ path: path.resolve("rollout-error.jsonl") }, { requireOriginalMatch: true }), + /Unexpected Windows rewrite worker response/ + ); + }); +}); + +test("applySessionChanges creates one Windows worker and does not queue the next target", { + skip: process.platform !== "win32" +}, async () => { + const firstPath = path.resolve("rollout-order-a.jsonl"); + const secondPath = path.resolve("rollout-order-b.jsonl"); + const changes = [firstPath, secondPath].map((filePath) => ({ + path: filePath, + originalMtimeMs: Date.now(), + modelRewriteRequired: false, + modelOnlyChange: false + })); + const events = []; + let factoryCalls = 0; + let closeCalls = 0; + + const result = await applySessionChanges(changes, { + windowsRewriteWorkerFactory: async () => { + factoryCalls += 1; + return { + async rewrite(change) { + events.push(`rewrite:${path.basename(change.path)}`); + return "APPLIED"; + }, + async close() { + closeCalls += 1; + } + }; + }, + async onBeforeApply(change) { + events.push(`applying:${path.basename(change.path)}`); + }, + async onApplied(change) { + events.push(`applied:${path.basename(change.path)}`); + } + }); + + assert.equal(factoryCalls, 1); + assert.equal(closeCalls, 1); + assert.equal(result.appliedChanges, 2); + assert.deepEqual(events, [ + "applying:rollout-order-a.jsonl", + "rewrite:rollout-order-a.jsonl", + "applied:rollout-order-a.jsonl", + "applying:rollout-order-b.jsonl", + "rewrite:rollout-order-b.jsonl", + "applied:rollout-order-b.jsonl" + ]); +}); + +test("real Windows worker handles sequential Unicode and literal wildcard paths", { + skip: process.platform !== "win32" +}, async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "provider-worker-test-")); + try { + const changes = []; + for (let index = 0; index < 2; index += 1) { + const filePath = path.join(root, `rollout-[测试 ${index}].jsonl`); + const originalFirstLine = JSON.stringify({ + type: "session_meta", + payload: { id: `thread-${index}`, model_provider: "old" } + }); + await fs.writeFile(filePath, `${originalFirstLine}\n{"type":"event_msg","payload":{}}\n`, "utf8"); + const stat = await fs.stat(filePath); + changes.push({ + path: filePath, + originalFirstLine, + originalSeparator: "\n", + originalOffset: Buffer.byteLength(originalFirstLine) + 1, + originalSize: stat.size, + updatedFirstLine: JSON.stringify({ + type: "session_meta", + payload: { id: `thread-${index}`, model_provider: "new" } + }) + }); + } + + const worker = await createWindowsExclusiveRewriteWorker(); + const results = []; + try { + for (const change of changes) { + results.push(await worker.rewrite(change, { requireOriginalMatch: true })); + } + } finally { + await worker.close(); + } + + assert.deepEqual(results, ["APPLIED", "APPLIED"]); + for (const change of changes) { + const [firstLine] = (await fs.readFile(change.path, "utf8")).split(/\r?\n/); + assert.equal(JSON.parse(firstLine).payload.model_provider, "new"); + } + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..c4f650d --- /dev/null +++ b/web/index.html @@ -0,0 +1,14 @@ + + + + + + + + Codex Provider Sync + + +
+ + + diff --git a/web/src/App.jsx b/web/src/App.jsx new file mode 100644 index 0000000..fd25458 --- /dev/null +++ b/web/src/App.jsx @@ -0,0 +1,1025 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { + PairingRequiredError, + ProfileRevisionError, + apiRequest, + forgetThisBrowser, + getActivity, + getHistory, + getHistorySession, + getProfiles, + initializeAccess +} from "./api.js"; +import { usePersistentState } from "./hooks.js"; +import { createLatestRequestGate, scheduleDebounced } from "./history-requests.js"; +import { captureProfileOperation, dedupeHistorySessions, operationToast, resolveRestoreTargetSqliteHome, restoreRelocationState } from "./operation-state.js"; +import { createProfileRefresh, storagePayload } from "./profile-refresh.js"; +import { + ActivityIcon, + AlertIcon, + CheckIcon, + ChevronIcon, + DatabaseIcon, + FolderIcon, + HistoryIcon, + OverviewIcon, + RefreshIcon, + ShieldIcon, + XIcon +} from "./icons.jsx"; + +const NAV_ITEMS = [ + { id: "overview", label: "概览", icon: OverviewIcon }, + { id: "history", label: "聊天记录", icon: HistoryIcon }, + { id: "backups", label: "备份", icon: HistoryIcon }, + { id: "activity", label: "活动", icon: ActivityIcon } +]; + +const EMPTY_BACKUPS = { backupRoot: "", backups: [] }; + +function formatNumber(value) { + return new Intl.NumberFormat("zh-CN").format(Number(value) || 0); +} + +function formatBytes(bytes) { + const units = ["B", "KB", "MB", "GB", "TB"]; + let value = Number(bytes) || 0; + let index = 0; + while (value >= 1024 && index < units.length - 1) { + value /= 1024; + index += 1; + } + return index === 0 ? `${value} B` : `${value.toFixed(value >= 10 ? 1 : 2).replace(/\.0$/, "")} ${units[index]}`; +} + +function formatDate(value) { + if (!value) return "未知时间"; + return new Intl.DateTimeFormat("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false + }).format(new Date(value)); +} + +function renderInlineMarkdown(text, keyPrefix) { + const parts = text.split(/(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*)/g); + return parts.map((part, index) => { + if (part.startsWith("`") && part.endsWith("`")) return {part.slice(1, -1)}; + if (part.startsWith("**") && part.endsWith("**")) return {part.slice(2, -2)}; + if (part.startsWith("*") && part.endsWith("*")) return {part.slice(1, -1)}; + return {part}; + }); +} + +function SafeMarkdown({ text }) { + const blocks = String(text ?? "").split(/(```[^\n]*\n[\s\S]*?```)/g); + return blocks.map((block, index) => { + if (block.startsWith("```") && block.endsWith("```")) { + const lines = block.slice(3, -3).replace(/^\w*\n/, ""); + return
{lines}
; + } + return block.split("\n").map((line, lineIndex, lines) => {renderInlineMarkdown(line, `${index}-${lineIndex}`)}{lineIndex < lines.length - 1 ?
: null}
); + }); +} + +function providersFromStatus(status) { + if (!status) return []; + const sources = new Map(); + const add = (values, source) => { + for (const value of values ?? []) { + if (!value || value === "(missing)") continue; + const bucket = sources.get(value) ?? new Set(); + bucket.add(source); + sources.set(value, bucket); + } + }; + add(status.configuredProviders, "config"); + add(Object.keys(status.rolloutCounts?.sessions ?? {}), "rollout"); + add(Object.keys(status.rolloutCounts?.archived_sessions ?? {}), "rollout"); + add(Object.keys(status.sqliteCounts?.sessions ?? {}), "sqlite"); + add(Object.keys(status.sqliteCounts?.archived_sessions ?? {}), "sqlite"); + add([status.currentProvider], "config"); + return [...sources.entries()] + .map(([id, providerSources]) => ({ + id, + sources: [...providerSources], + configured: status.configuredProviders?.includes(id), + current: id === status.currentProvider + })) + .sort((left, right) => Number(right.current) - Number(left.current) || left.id.localeCompare(right.id)); +} + +function StatusDot({ tone = "neutral" }) { + return