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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ Telegram 频道:<https://t.me/CodexPlusPlus>

| 模块 | 功能 |
| --- | --- |
| 供应商配置 | 官方登录、官方登录混入 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、图片覆盖层 |
Expand All @@ -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 导出和项目移动。
Expand Down
8 changes: 7 additions & 1 deletion README_EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ Friendly link: <a href="https://linux.do">LINUX DO</a>

| 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 |
Expand All @@ -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.
Expand Down
88 changes: 88 additions & 0 deletions apps/codex-plus-manager/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<codex_plus_core::remote_relay_sync::RemoteRelaySyncResult>,
}

#[tauri::command]
pub fn switch_relay_profile(
request: RelayProfileSwitchRequest,
Expand Down Expand Up @@ -2972,6 +2978,88 @@ pub fn switch_relay_profile(
}
}

#[tauri::command]
pub async fn sync_relay_profile_remote(
settings: BackendSettings,
) -> CommandResult<RemoteRelaySyncPayload> {
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<Value> {
let event = sanitize_manager_event(&event);
Expand Down
1 change: 1 addition & 0 deletions apps/codex-plus-manager/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
158 changes: 158 additions & 0 deletions apps/codex-plus-manager/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,9 @@ type BackendSettings = {
providerSyncManualProviders: string[];
providerSyncLastSelectedProvider: string;
relayProfilesEnabled: boolean;
relayRemoteSyncEnabled: boolean;
relayRemoteSshTarget: string;
relayRemoteCodexHome: string;
enhancementsEnabled: boolean;
computerUseGuardEnabled: boolean;
codexAppPluginMarketplaceUnlock: boolean;
Expand Down Expand Up @@ -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;
}>;
Expand Down Expand Up @@ -792,6 +805,9 @@ const defaultSettings: BackendSettings = {
providerSyncManualProviders: [],
providerSyncLastSelectedProvider: "",
relayProfilesEnabled: true,
relayRemoteSyncEnabled: false,
relayRemoteSshTarget: "",
relayRemoteCodexHome: "",
enhancementsEnabled: true,
computerUseGuardEnabled: false,
codexAppPluginMarketplaceUnlock: true,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -2409,6 +2429,86 @@ export function App() {
launchMode: selectedSettings.launchMode,
status: result.status,
});
if (selectedSettings.relayRemoteSyncEnabled) {
const remoteResult = await run(() =>
call<RemoteRelaySyncResult>("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<SettingsResult>("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<RemoteRelaySyncResult>("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);
}
Expand Down Expand Up @@ -2765,6 +2865,7 @@ export function App() {
fetchRelayProfileModels,
fetchSub2ApiBilling,
switchRelayProfile,
syncCurrentRelayProfileRemote,
relaySwitching,
switchOfficialMode,
switchPureApiMode,
Expand Down Expand Up @@ -3127,6 +3228,7 @@ type Actions = {
fetchRelayProfileModels: (profile: RelayProfile) => Promise<string[] | null>;
fetchSub2ApiBilling: (profile: RelayProfile) => Promise<Sub2ApiBillingResult | null>;
switchRelayProfile: (settings: BackendSettings, previousActiveRelayId?: string) => Promise<void>;
syncCurrentRelayProfileRemote: (settings: BackendSettings) => Promise<void>;
relaySwitching: boolean;
switchOfficialMode: () => Promise<void>;
switchPureApiMode: () => Promise<void>;
Expand Down Expand Up @@ -3427,6 +3529,62 @@ function RelayScreen({
</span>
<ToggleVisual />
</label>
<label className="switch-row relay-remote-switch">
<input
checked={normalized.relayRemoteSyncEnabled}
onChange={(event) => {
const next = { ...normalized, relayRemoteSyncEnabled: event.currentTarget.checked };
void saveRelaySettings(next);
}}
type="checkbox"
/>
<span>
<strong>{t("同步到 SSH 远端")}</strong>
<small>{t("切换供应商时同步路由与认证;远端会先备份,并保留项目、插件和功能配置。")}</small>
</span>
<ToggleVisual />
</label>
{normalized.relayRemoteSyncEnabled ? (
<div className="relay-remote-fields">
<Field label={t("SSH 主机别名")}>
<Input
value={normalized.relayRemoteSshTarget}
onChange={(event) => {
void saveRelaySettings({
...normalized,
relayRemoteSshTarget: event.currentTarget.value,
});
}}
placeholder={t("例如 build-host 或 user@example.com")}
/>
</Field>
<Field label={t("远端 CODEX_HOME")}>
<Input
value={normalized.relayRemoteCodexHome}
onChange={(event) => {
void saveRelaySettings({
...normalized,
relayRemoteCodexHome: event.currentTarget.value,
});
}}
placeholder={t("留空使用 ~/.codex")}
/>
</Field>
<Button
className="relay-remote-sync-button"
disabled={!normalized.relayRemoteSshTarget.trim() || actions.relaySwitching}
onClick={() => void actions.syncCurrentRelayProfileRemote(normalized)}
title={t("把当前供应商的路由与认证立即同步到 SSH 远端")}
variant="outline"
>
<RefreshCw className={`h-4 w-4 ${actions.relaySwitching ? "spin" : ""}`} />
{actions.relaySwitching ? t("同步中") : t("立即同步")}
</Button>
<small className="relay-remote-hint">
{t("需要本机 OpenSSH 和无交互密钥认证;仅同步到你信任的主机。")}
</small>
</div>
) : null}
<div className="relay-add-row">
<Button
variant="secondary"
Expand Down
Loading