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
2 changes: 2 additions & 0 deletions PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ GSwitch may:
- consume an eligible reset credit only after an explicit user action;
- Wake one or more eligible accounts only after an explicit user action;
- expose the minimum settings and recovery actions required by those jobs.
- show the Codex CLI that GSwitch uses and request its official update when a
local CLI problem prevents those jobs.

Multiple intake paths still end in one local saved-account model. They do not
create a general provider platform.
Expand Down
2 changes: 2 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ Keep the backend flat and organized by concrete responsibility:
Server;
- `chatgpt.rs`: read-only ChatGPT quota and account-metadata HTTP boundary;
- `codex.rs`: `CODEX_HOME`, effective storage mode, and live auth-file access;
- `cli_update.rs`: version inspection and explicit handoff to the installed
Codex CLI's official update command;
- `identity.rs`: credential classification, stable non-secret identity, and
fingerprints used for comparisons;
- `intake.rs`: OAuth, bounded auth-document batch import, versioned portable export serialization, and API-key intake;
Expand Down
7 changes: 7 additions & 0 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,13 @@ explicit reset preserves GSwitch's damaged file without touching Codex.

## Interaction states

The toolbar offers a small **Codex CLI** entry. Its focused dialog shows the
version of the CLI GSwitch actually launches and an explicit update action only
when that CLI exposes the official `update` command. Do not claim a version is
the latest without checking a source for that claim. A missing or unsupported
CLI points to the official install guide. Update progress and failures stay in
the dialog; the account grid remains the primary workspace.

- Do not optimistically display a switch or redemption as complete.
- Opening a dialog moves keyboard focus inside it. Tab stays inside, and closing
restores focus to the launching control. A dialog cannot be dismissed while
Expand Down
8 changes: 8 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,14 @@ GSwitch-owned isolated App Server children are scoped to their operation and
excluded only from that operation's external-process check. They are terminated
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
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
manual guidance. Tests use a fake CLI and never update the user's installation.

Saving the current file-backed ChatGPT account uses the live access-token
snapshot for a Rust-only read-only account check. It matches the returned
workspace to the locally derived stable identity, rereads the live credential
Expand Down
9 changes: 9 additions & 0 deletions src-tauri/src/accounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -738,6 +738,15 @@ impl AppState {
.map_err(|error| error.to_string())
}

/// 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()
}

pub(crate) fn acquire_operation_for_switch(
&self,
) -> Result<OperationGuard<'_>, OperationAcquireFailure> {
Expand Down
75 changes: 52 additions & 23 deletions src-tauri/src/app_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -321,16 +321,8 @@ impl Drop for AppServer {
}

fn app_server_command(codex_home: &Path) -> Command {
#[cfg(windows)]
let mut command = windows_app_server_command();

#[cfg(not(windows))]
let mut command = {
let mut command =
Command::new(configured_codex_binary().unwrap_or_else(|| PathBuf::from("codex")));
command.arg("app-server");
command
};
let mut command = codex_command();
command.arg("app-server");

command
.current_dir(codex_home)
Expand Down Expand Up @@ -360,26 +352,57 @@ fn app_server_command(codex_home: &Path) -> Command {
command
}

#[cfg(windows)]
fn windows_app_server_command() -> Command {
/// Resolve the same installed Codex CLI for App Server and explicit CLI
/// maintenance. A desktop process does not necessarily inherit a shell PATH.
pub(crate) fn codex_command() -> Command {
#[cfg(windows)]
{
if let Some(path) = configured_codex_binary() {
return windows_codex_command_for(&path);
}
}

#[cfg(not(windows))]
if let Some(path) = configured_codex_binary() {
return windows_app_server_command_for(&path);
return Command::new(path);
}

let mut command = Command::new("codex");
command.arg("app-server");
command
Command::new("codex")
}

#[cfg(windows)]
fn windows_app_server_command_for(path: &Path) -> Command {
fn windows_codex_command_for(path: &Path) -> Command {
if path
.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("cmd"))
{
if let Some(native) = windows_npm_codex_executable(path) {
let mut command = Command::new(native);
command.arg("app-server");
// The official codex.js launcher supplies this install context to
// the native process. Preserve it when launching directly so
// `codex update` chooses npm rather than an unknown install method.
for key in [
"CODEX_MANAGED_BY_BUN",
"CODEX_MANAGED_BY_PNPM",
"CODEX_MANAGED_BY_VITE_PLUS",
] {
command.env_remove(key);
}
command.env("CODEX_MANAGED_BY_NPM", "1");
if let Some(npm_dir) = path.parent() {
command.env(
"CODEX_MANAGED_PACKAGE_ROOT",
npm_dir.join("node_modules").join("@openai").join("codex"),
);
// A desktop process may not have the npm shim directory on PATH.
// The CLI's own updater starts npm from this installation.
let mut search_path = std::ffi::OsString::from(npm_dir.as_os_str());
if let Some(existing) = env::var_os("PATH") {
search_path.push(";");
search_path.push(existing);
}
command.env("PATH", search_path);
}
return command;
}
if let Some(npm_dir) = path.parent() {
Expand All @@ -399,15 +422,13 @@ fn windows_app_server_command_for(path: &Path) -> Command {
} else {
Command::new("node.exe")
};
command.args([script, PathBuf::from("app-server")]);
command.arg(script);
return command;
}
}
}

let mut command = Command::new(path);
command.arg("app-server");
command
Command::new(path)
}

#[cfg(windows)]
Expand Down Expand Up @@ -719,9 +740,17 @@ mod tests {
fs::write(&shim, b"npm shim fixture").expect("shim");
fs::write(&native, b"native binary fixture").expect("native executable");

let command = windows_app_server_command_for(&shim);
let mut command = windows_codex_command_for(&shim);
command.arg("app-server");
assert_eq!(command.get_program(), native.as_os_str());
assert_eq!(command.get_args().next(), Some(OsStr::new("app-server")));
assert_eq!(
command
.get_envs()
.find(|(key, _)| *key == OsStr::new("CODEX_MANAGED_BY_NPM"))
.and_then(|(_, value)| value),
Some(OsStr::new("1"))
);
fs::remove_dir_all(root).expect("remove fixture");
}

Expand Down
213 changes: 213 additions & 0 deletions src-tauri/src/cli_update.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
use std::{
io::Read,
process::{Command, Stdio},
thread,
time::{Duration, Instant},
};

use crate::{
accounts::{AppState, OperationAcquireFailure},
app_server, runtime,
types::{CodexCliInfo, CodexCliUpdateFailure},
};

const PROBE_TIMEOUT: Duration = Duration::from_secs(5);
const MAX_PROBE_OUTPUT: usize = 32 * 1024;

/// Only the version and presence of the official update subcommand cross IPC.
/// Never return CLI diagnostics, environment values, paths, or command output.
pub fn inspect() -> CodexCliInfo {
inspect_with(app_server::codex_command)
}

fn inspect_with(mut command: impl FnMut() -> Command) -> CodexCliInfo {
let version = probe(command(), "--version")
.as_deref()
.and_then(parse_version);
let supports_update = version.is_some()
&& probe(command(), "--help")
.as_deref()
.is_some_and(help_lists_update);
CodexCliInfo {
version,
supports_update,
}
}

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,
})?;
let before = inspect();
if before.version.is_none() {
return Err(CodexCliUpdateFailure::NotInstalled);
}
if !before.supports_update {
return Err(CodexCliUpdateFailure::Unsupported);
}
runtime::ensure_no_external_codex(&[]).map_err(|_| CodexCliUpdateFailure::CodexOpen)?;

run_official_update(app_server::codex_command())?;

let after = inspect();
if after.version.is_none() {
return Err(CodexCliUpdateFailure::VerificationFailed);
}
Ok(after)
}

fn run_official_update(mut command: Command) -> Result<(), CodexCliUpdateFailure> {
command
.arg("update")
.current_dir(std::env::temp_dir())
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
hide_console(&mut command);
let status = command
.status()
.map_err(|_| CodexCliUpdateFailure::UpdateFailed)?;
if !status.success() {
return Err(CodexCliUpdateFailure::UpdateFailed);
}
Ok(())
}

fn probe(mut command: Command, argument: &str) -> Option<String> {
command
.arg(argument)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null());
hide_console(&mut command);
let mut child = command.spawn().ok()?;
let Some(mut stdout) = child.stdout.take() else {
let _ = child.kill();
let _ = child.wait();
return None;
};
let reader = thread::Builder::new()
.name("gswitch-cli-probe".to_string())
.spawn(move || {
let mut captured = Vec::new();
let mut chunk = [0_u8; 1024];
loop {
let count = stdout.read(&mut chunk).ok()?;
if count == 0 {
break;
}
let keep = count.min(MAX_PROBE_OUTPUT.saturating_sub(captured.len()));
captured.extend_from_slice(&chunk[..keep]);
}
String::from_utf8(captured).ok()
})
.ok();
let Some(reader) = reader else {
let _ = child.kill();
let _ = child.wait();
return None;
};

let deadline = Instant::now() + PROBE_TIMEOUT;
let success = loop {
match child.try_wait() {
Ok(Some(status)) => break status.success(),
Ok(None) if Instant::now() < deadline => thread::sleep(Duration::from_millis(30)),
Ok(None) | Err(_) => {
let _ = child.kill();
let _ = child.wait();
break false;
}
}
};
if success {
reader.join().ok().flatten()
} else {
// A launcher may leave a grandchild holding stdout open. Do not make
// the five-second timeout wait indefinitely for that pipe to close.
None
}
}

fn parse_version(output: &str) -> Option<String> {
let mut words = output.split_whitespace();
if words.next()? != "codex-cli" {
return None;
}
let version = words.next()?;
if version.len() > 48
|| !version
.chars()
.all(|c| c.is_ascii_alphanumeric() || ".+-".contains(c))
{
return None;
}
let core = version.split(['-', '+']).next()?;
if core.split('.').count() != 3 || !core.split('.').all(|part| part.parse::<u64>().is_ok()) {
return None;
}
Some(version.to_string())
}

fn help_lists_update(output: &str) -> bool {
output
.lines()
.any(|line| line.split_whitespace().next() == Some("update"))
}

#[cfg(windows)]
fn hide_console(command: &mut Command) {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
command.creation_flags(CREATE_NO_WINDOW);
}

#[cfg(not(windows))]
fn hide_console(_command: &mut Command) {}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn accepts_only_codex_cli_versions_and_an_explicit_update_command() {
assert_eq!(
parse_version("codex-cli 0.156.1\n"),
Some("0.156.1".to_string())
);
assert_eq!(
parse_version("codex-cli 0.157.0-beta.1\n"),
Some("0.157.0-beta.1".to_string())
);
assert_eq!(parse_version("other-cli 0.156.1\n"), None);
assert_eq!(parse_version("codex-cli not-a-version\n"), None);
assert!(help_lists_update(
"Commands:\n update Update Codex to the latest version\n"
));
assert!(!help_lists_update("Commands:\n exec Run Codex\n"));
}

#[cfg(windows)]
#[test]
fn inspects_a_fake_windows_cli_without_running_its_update_action() {
let root =
std::env::temp_dir().join(format!("gswitch-cli-fixture-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&root).expect("fixture dir");
let script = root.join("codex.cmd");
std::fs::write(
&script,
"@echo off\r\nif \"%1\"==\"--version\" echo codex-cli 0.156.1\r\nif \"%1\"==\"--help\" echo update Update Codex\r\nif \"%1\"==\"update\" echo called>\"%~dp0updated.txt\"\r\n",
)
.expect("fixture script");
let info = inspect_with(|| Command::new(&script));
assert_eq!(info.version.as_deref(), Some("0.156.1"));
assert!(info.supports_update);
assert!(!root.join("updated.txt").exists());
run_official_update(Command::new(&script)).expect("fake CLI update");
assert!(root.join("updated.txt").exists());
std::fs::remove_dir_all(root).expect("remove fixture");
}
}
Loading
Loading