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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@ when the operation ends.

GSwitch reads the installed CLI version from the same executable it uses for
App Server. It updates the CLI only after a user selects **Update CLI**, with
the GSwitch operation lock held and no external Codex runtime active. The CLI
the GSwitch operation lock held and no external Codex runtime active. When
GSwitch's own work, such as a quota refresh, holds that lock, the update waits
for it to finish instead of failing. The CLI
itself chooses its supported installation method; GSwitch does not download or
replace CLI files or change account credentials. A completed update is followed
by a fresh version read. Missing or unsupported update commands use official
Expand Down
42 changes: 36 additions & 6 deletions src-tauri/src/accounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,10 @@ impl AppState {
/// the short quota commit still uses the cross-process storage boundary.
pub(crate) fn acquire_quota_commit_operation(&self) -> Result<OperationGuard<'_>, String> {
self.ensure_store_ready()?;
self.wait_for_operation_lock()
}

fn wait_for_operation_lock(&self) -> Result<OperationGuard<'_>, String> {
let in_process = self
.operation_lock
.lock()
Expand All @@ -804,12 +808,12 @@ impl AppState {
}

/// Updating the installed CLI must not overlap a GSwitch operation that
/// may launch App Server. This remains available when account storage
/// needs recovery because it never reads or changes saved accounts.
pub(crate) fn acquire_cli_update_operation(
&self,
) -> Result<OperationGuard<'_>, OperationAcquireFailure> {
self.acquire_operation_lock()
/// may launch App Server. It waits for GSwitch's own running work, such as
/// a quota refresh, instead of reporting it as a conflict. This remains
/// available when account storage needs recovery because it never reads
/// or changes saved accounts.
pub(crate) fn acquire_cli_update_operation(&self) -> Result<OperationGuard<'_>, String> {
self.wait_for_operation_lock()
}

pub(crate) fn acquire_operation_for_switch(
Expand Down Expand Up @@ -2867,6 +2871,32 @@ mod tests {
let _ = fs::remove_dir_all(path.parent().expect("parent"));
}

#[test]
fn cli_update_waits_for_a_running_operation_instead_of_failing() {
use std::{sync::mpsc, thread, time::Duration};

let path = temp_path("cli-update-waits");
let state = AppState::new(path.clone()).expect("state");
let refresh = state.acquire_operation().expect("running operation");

let (result_tx, result_rx) = mpsc::channel();
let waiting = state.clone();
let worker = thread::spawn(move || {
let acquired = waiting.acquire_cli_update_operation().is_ok();
result_tx.send(acquired).expect("send result");
});
assert!(matches!(
result_rx.recv_timeout(Duration::from_millis(100)),
Err(mpsc::RecvTimeoutError::Timeout)
));
drop(refresh);
assert!(result_rx
.recv_timeout(Duration::from_secs(5))
.expect("update proceeded after the operation finished"));
worker.join().expect("worker finished");
let _ = fs::remove_dir_all(path.parent().expect("parent"));
}

#[test]
fn secret_generation_overflow_fails_before_replacing_the_current_secret() {
assert!(AppState::next_secret_generation(Some(u64::MAX), 1).is_err());
Expand Down
7 changes: 2 additions & 5 deletions src-tauri/src/cli_update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use std::{
};

use crate::{
accounts::{AppState, OperationAcquireFailure},
accounts::AppState,
app_server, runtime,
types::{CodexCliInfo, CodexCliUpdateFailure, CodexCliUpdateStatus},
};
Expand Down Expand Up @@ -118,10 +118,7 @@ fn newer_than(latest: &str, installed: &str) -> bool {
pub fn update(state: &AppState) -> Result<CodexCliInfo, CodexCliUpdateFailure> {
let _operation = state
.acquire_cli_update_operation()
.map_err(|error| match error {
OperationAcquireFailure::Busy => CodexCliUpdateFailure::Busy,
OperationAcquireFailure::Failed(_) => CodexCliUpdateFailure::UpdateFailed,
})?;
.map_err(|_| CodexCliUpdateFailure::UpdateFailed)?;
let before = inspect();
if before.version.is_none() {
return Err(CodexCliUpdateFailure::NotInstalled);
Expand Down
1 change: 0 additions & 1 deletion src-tauri/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ pub enum CodexCliUpdateStatus {
pub enum CodexCliUpdateFailure {
NotInstalled,
Unsupported,
Busy,
CodexOpen,
UpdateFailed,
VerificationFailed,
Expand Down
3 changes: 1 addition & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ function accountGridHasGlobalMutation(busy: string | null) {

function cliUpdateFailureCode(error: unknown): CodexCliUpdateFailure {
const code = String(error);
if (["not_installed", "unsupported", "busy", "codex_open", "update_failed", "verification_failed"].includes(code)) {
if (["not_installed", "unsupported", "codex_open", "update_failed", "verification_failed"].includes(code)) {
return code as CodexCliUpdateFailure;
}
return "update_failed";
Expand Down Expand Up @@ -2490,7 +2490,6 @@ export default function App() {
{cliError ? <p className="cli-error" role="alert">{t(({
not_installed: "cli.error.notInstalled",
unsupported: "cli.error.unsupported",
busy: "cli.error.busy",
codex_open: "cli.error.codexOpen",
update_failed: "cli.error.updateFailed",
verification_failed: "cli.error.verificationFailed",
Expand Down
2 changes: 0 additions & 2 deletions src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,6 @@ const en = {
"cli.guide": "Official install guide",
"cli.error.notInstalled": "The Codex CLI is unavailable. Install it, then check again.",
"cli.error.unsupported": "This CLI cannot update itself. Use its installer or package manager.",
"cli.error.busy": "Another GSwitch operation is running. Try again when it finishes.",
"cli.error.codexOpen": "Quit Codex, then try the update again.",
"cli.error.updateFailed": "The official Codex update did not finish. Try updating from a terminal or use the install guide.",
"cli.error.verificationFailed": "The update command finished, but GSwitch could not read the installed CLI version. Check the installation.",
Expand Down Expand Up @@ -471,7 +470,6 @@ const zhCN: Record<keyof typeof en, string> = {
"cli.guide": "官方安装指南",
"cli.error.notInstalled": "Codex CLI 不可用。请安装后重新检查。",
"cli.error.unsupported": "此 CLI 不支持自行更新。请通过原安装程序或包管理器更新。",
"cli.error.busy": "另一项 GSwitch 操作正在进行,请完成后重试。",
"cli.error.codexOpen": "请退出 Codex 后再更新。",
"cli.error.updateFailed": "Codex 官方更新未完成。请在终端中更新,或查看安装指南。",
"cli.error.verificationFailed": "更新命令已结束,但 GSwitch 无法读取已安装的 CLI 版本。请检查安装。",
Expand Down
1 change: 0 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ export interface CodexCliInfo {
export type CodexCliUpdateFailure =
| "not_installed"
| "unsupported"
| "busy"
| "codex_open"
| "update_failed"
| "verification_failed";
Expand Down
Loading