diff --git a/README.md b/README.md index 7ae697c31..53da9c18e 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,7 @@ Telegram 频道: | 模块 | 功能 | | --- | --- | -| 供应商配置 | 官方登录、官方登录混入 API、纯 API、聚合供应商;Responses / Chat Completions;模型测试、模型列表、Provider Doctor、cc-switch 与链接导入 | +| 供应商配置 | 官方登录、官方登录混入 API、纯 API、聚合供应商;Responses / Chat Completions;SSH 远端同步、模型测试、模型列表、Provider Doctor、cc-switch 与链接导入 | | 模型与上下文 | 每模型上下文窗口、自动压缩阈值、`model_catalog_json`、通用配置,以及按供应商选择 MCP、Skill 和 Plugin | | 会话管理 | 扫描本地会话、批量删除、Markdown 导出、Token 用量历史、Provider metadata 同步与备份 | | Codex 增强 | 插件市场与模型白名单、会话操作、粘贴修复、中文界面、快速启动、会话宽度与滚动恢复、服务层级控制、Goals、Stepwise、图片覆盖层 | @@ -199,6 +199,12 @@ Codex++ 将官方登录、混入 API 和纯 API 分开保存和切换: 切换供应商时会先保存当前配置,再写入目标配置。真实 API Key 只保存在本机,请勿放入日志、截图或 issue。 +### SSH 远端同步 + +供应商列表可选择将当前供应商同步到 SSH 主机。该功能要求本机 `ssh` 可用,并已配置无需交互输入密码的密钥认证;远端需要 POSIX shell 和 `base64` 命令。 + +同步只更新当前供应商的路由字段和 `auth.json`,保留远端的项目、插件与功能配置。写入前会备份到远端 `~/.codex/backups/remote-provider-switch-*`,文件以 `600` 权限原子替换;若受管的 app-server 正在运行,会停止旧进程并等待客户端自动重连。认证内容仅通过 SSH 标准输入传输,不会写入命令行参数。请只同步到你信任的主机。 + ## Codex 界面增强 - 会话删除、批量删除、Markdown 导出和项目移动。 diff --git a/README_EN.md b/README_EN.md index 6dc81ac4d..37c5ae1ce 100644 --- a/README_EN.md +++ b/README_EN.md @@ -159,7 +159,7 @@ Friendly link: LINUX DO | Area | Capabilities | | --- | --- | -| Provider configuration | Official login, official login plus API, pure API, and aggregate providers; Responses / Chat Completions; model tests, model discovery, Provider Doctor, cc-switch and deep-link imports | +| Provider configuration | Official login, official login plus API, pure API, and aggregate providers; Responses / Chat Completions; SSH remote sync, model tests, model discovery, Provider Doctor, cc-switch and deep-link imports | | Models and context | Per-model context windows, auto-compact limits, `model_catalog_json`, shared config, and per-provider MCP, Skill, and Plugin selection | | Session management | Local session scanning, bulk deletion, Markdown export, token usage history, Provider metadata sync, and backups | | Codex enhancements | Plugin marketplace and model whitelist handling, session actions, paste fix, Chinese locale, fast startup, conversation width and scroll restore, service-tier controls, Goals, Stepwise, and image overlay | @@ -185,6 +185,12 @@ Per-model windows accept values such as `1M`, `200K`, or plain integers. Codex++ Provider switching saves the current profile before applying the target profile. Real API keys remain local and should never be posted in logs, screenshots, or issues. +### SSH Remote Sync + +The provider list can optionally synchronize the active provider to an SSH host. The local machine must provide `ssh` with non-interactive key authentication; the remote host needs a POSIX shell and `base64`. + +Synchronization updates only the active provider routing fields and `auth.json`, preserving remote project, plugin, and feature configuration. Before replacement, Codex++ creates a backup under `~/.codex/backups/remote-provider-switch-*`; files are atomically replaced with `600` permissions. A managed app-server is stopped so the client can reconnect with the new provider. Credentials travel only through SSH standard input and never appear in command-line arguments. Only synchronize to hosts you trust. + ## Codex Enhancements - Session delete, bulk delete, Markdown export, and project move actions. diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index c1810f018..d35a96b99 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -2906,6 +2906,12 @@ pub struct RelayProfileSwitchRequest { pub previous_active_relay_id: String, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteRelaySyncPayload { + pub result: Option, +} + #[tauri::command] pub fn switch_relay_profile( request: RelayProfileSwitchRequest, @@ -2972,6 +2978,88 @@ pub fn switch_relay_profile( } } +#[tauri::command] +pub async fn sync_relay_profile_remote( + settings: BackendSettings, +) -> CommandResult { + let settings = normalize_settings_before_save(settings); + if !settings.relay_remote_sync_enabled { + return failed( + "SSH 远端同步未启用。", + RemoteRelaySyncPayload { result: None }, + ); + } + let target = settings.relay_remote_ssh_target.clone(); + let codex_home = settings.relay_remote_codex_home.clone(); + let profile = settings.active_relay_profile(); + let profile_id = profile.id.clone(); + log_manager_event( + "manager.sync_relay_profile_remote.start", + json!({ + "targetRelayId": profile.id, + "sshTarget": target, + "remoteCodexHome": codex_home + }), + ); + let sync_target = target.clone(); + let sync_codex_home = codex_home.clone(); + let sync_result = tauri::async_runtime::spawn_blocking(move || { + codex_plus_core::remote_relay_sync::sync_relay_profile_to_ssh( + &sync_target, + &sync_codex_home, + &profile, + ) + }) + .await; + match sync_result { + Ok(Ok(result)) => { + log_manager_event( + "manager.sync_relay_profile_remote.ok", + json!({ + "targetRelayId": profile_id, + "sshTarget": target, + "backupPath": result.backup_path, + "appServerRestarted": result.app_server_restarted + }), + ); + ok( + "远端供应商已同步,旧 app-server 已停止并等待 Codex 自动重连。", + RemoteRelaySyncPayload { + result: Some(result), + }, + ) + } + Ok(Err(error)) => { + log_manager_event( + "manager.sync_relay_profile_remote.failed", + json!({ + "targetRelayId": profile_id, + "sshTarget": target, + "error": error.to_string() + }), + ); + failed( + &format!("远端供应商同步失败:{error}"), + RemoteRelaySyncPayload { result: None }, + ) + } + Err(error) => { + log_manager_event( + "manager.sync_relay_profile_remote.join_failed", + json!({ + "targetRelayId": profile_id, + "sshTarget": target, + "error": error.to_string() + }), + ); + failed( + &format!("远端供应商同步任务异常:{error}"), + RemoteRelaySyncPayload { result: None }, + ) + } + } +} + #[tauri::command] pub fn write_diagnostic_event(event: String, detail: Value) -> CommandResult { let event = sanitize_manager_event(&event); diff --git a/apps/codex-plus-manager/src-tauri/src/lib.rs b/apps/codex-plus-manager/src-tauri/src/lib.rs index da987661a..d625b7a45 100644 --- a/apps/codex-plus-manager/src-tauri/src/lib.rs +++ b/apps/codex-plus-manager/src-tauri/src/lib.rs @@ -150,6 +150,7 @@ pub fn run() { commands::fetch_relay_profile_models, commands::fetch_sub2api_billing, commands::switch_relay_profile, + commands::sync_relay_profile_remote, commands::apply_relay_injection, commands::apply_pure_api_injection, commands::clear_relay_injection, diff --git a/apps/codex-plus-manager/src/App.tsx b/apps/codex-plus-manager/src/App.tsx index 211ce5959..6d04ebe35 100644 --- a/apps/codex-plus-manager/src/App.tsx +++ b/apps/codex-plus-manager/src/App.tsx @@ -193,6 +193,9 @@ type BackendSettings = { providerSyncManualProviders: string[]; providerSyncLastSelectedProvider: string; relayProfilesEnabled: boolean; + relayRemoteSyncEnabled: boolean; + relayRemoteSshTarget: string; + relayRemoteCodexHome: string; enhancementsEnabled: boolean; computerUseGuardEnabled: boolean; codexAppPluginMarketplaceUnlock: boolean; @@ -454,6 +457,16 @@ type RelaySwitchResult = CommandResult<{ relay: RelayPayload; }>; +type RemoteRelaySyncResult = CommandResult<{ + result: { + sshTarget: string; + codexHome: string; + backupPath: string; + modelProvider: string; + appServerRestarted: boolean; + } | null; +}>; + type SettingsBackfillResult = CommandResult<{ settings: BackendSettings; }>; @@ -792,6 +805,9 @@ const defaultSettings: BackendSettings = { providerSyncManualProviders: [], providerSyncLastSelectedProvider: "", relayProfilesEnabled: true, + relayRemoteSyncEnabled: false, + relayRemoteSshTarget: "", + relayRemoteCodexHome: "", enhancementsEnabled: true, computerUseGuardEnabled: false, codexAppPluginMarketplaceUnlock: true, @@ -2337,6 +2353,10 @@ export function App() { showNotice(t("供应商配置已关闭"), t("当前不会写入 Codex config.toml / auth.json。打开供应商配置总开关后再切换。"), "failed"); return; } + if (switchSettings.relayRemoteSyncEnabled && !switchSettings.relayRemoteSshTarget.trim()) { + showNotice(t("远端同步配置不完整"), t("请先填写 SSH 主机别名。"), "failed"); + return; + } const targetBeforeSnapshot = activeRelayProfile(switchSettings); logDiagnostic("switchRelayProfile.start", { currentRelayId: settingsForm.activeRelayId, @@ -2409,6 +2429,86 @@ export function App() { launchMode: selectedSettings.launchMode, status: result.status, }); + if (selectedSettings.relayRemoteSyncEnabled) { + const remoteResult = await run(() => + call("sync_relay_profile_remote", { + settings: selectedSettings, + }), + ); + if (!remoteResult || !isSuccessStatus(remoteResult.status)) { + const message = remoteResult?.message || t("本地已切换,但远端同步没有返回结果。"); + showNotice(t("供应商切换"), tf("本地已切换;{0}", [message]), "failed"); + return; + } + showNotice( + t("供应商切换"), + tf("本地与 {0} 已切换到 {1}。", [selectedSettings.relayRemoteSshTarget, currentSelected.name]), + "ok", + ); + } else { + showNotice(t("供应商切换"), tf("本地已切换到 {0}。", [currentSelected.name]), "ok"); + } + } finally { + setRelaySwitching(false); + } + }; + + const syncCurrentRelayProfileRemote = async (next: BackendSettings) => { + if (relaySwitching) { + showNotice(t("SSH 远端同步"), t("供应商操作还没有完成,请稍后再试。"), "failed"); + return; + } + let syncSettings = normalizeSettings(next); + if (!syncSettings.relayRemoteSyncEnabled) { + showNotice(t("SSH 远端同步"), t("请先启用 SSH 远端同步。"), "failed"); + return; + } + if (!syncSettings.relayRemoteSshTarget.trim()) { + showNotice(t("SSH 远端同步"), t("请先填写 SSH 主机别名。"), "failed"); + return; + } + const selected = activeRelayProfile(syncSettings); + const validationError = relayProfileSwitchValidation(selected); + if (validationError) { + showNotice(t("供应商配置可能不正确"), validationError, "failed"); + return; + } + + setRelaySwitching(true); + try { + syncSettings = await snapshotActiveRelayFilesBeforeSwitch(syncSettings, selected.id); + const saveResult = await run(() => call("save_settings", { settings: syncSettings })); + if (!saveResult || !isSuccessStatus(saveResult.status)) { + showNotice( + t("SSH 远端同步"), + saveResult?.message || t("保存当前供应商快照失败,已停止远端同步。"), + "failed", + ); + return; + } + syncSettings = normalizeSettings(saveResult.settings); + setSettings(saveResult); + setSettingsForm(syncSettings); + + const remoteResult = await run(() => + call("sync_relay_profile_remote", { + settings: syncSettings, + }), + ); + if (!remoteResult || !isSuccessStatus(remoteResult.status)) { + showNotice( + t("SSH 远端同步"), + remoteResult?.message || t("远端同步没有返回结果。"), + "failed", + ); + return; + } + const current = activeRelayProfile(syncSettings); + showNotice( + t("SSH 远端同步"), + tf("已将 {0} 同步到 {1}。", [current.name, syncSettings.relayRemoteSshTarget]), + "ok", + ); } finally { setRelaySwitching(false); } @@ -2765,6 +2865,7 @@ export function App() { fetchRelayProfileModels, fetchSub2ApiBilling, switchRelayProfile, + syncCurrentRelayProfileRemote, relaySwitching, switchOfficialMode, switchPureApiMode, @@ -3127,6 +3228,7 @@ type Actions = { fetchRelayProfileModels: (profile: RelayProfile) => Promise; fetchSub2ApiBilling: (profile: RelayProfile) => Promise; switchRelayProfile: (settings: BackendSettings, previousActiveRelayId?: string) => Promise; + syncCurrentRelayProfileRemote: (settings: BackendSettings) => Promise; relaySwitching: boolean; switchOfficialMode: () => Promise; switchPureApiMode: () => Promise; @@ -3427,6 +3529,62 @@ function RelayScreen({ + + {normalized.relayRemoteSyncEnabled ? ( +
+ + { + void saveRelaySettings({ + ...normalized, + relayRemoteSshTarget: event.currentTarget.value, + }); + }} + placeholder={t("例如 build-host 或 user@example.com")} + /> + + + { + void saveRelaySettings({ + ...normalized, + relayRemoteCodexHome: event.currentTarget.value, + }); + }} + placeholder={t("留空使用 ~/.codex")} + /> + + + + {t("需要本机 OpenSSH 和无交互密钥认证;仅同步到你信任的主机。")} + +
+ ) : null}