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: 1 addition & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ _Avoid_: 迁移、合并工作区

## Provider

AgentNave 可启动的本地 CLI 智能体。目前支持 Antigravity CLI、Claude Code、CodeBuddy Code、Codex CLI、Grok CLI、Pi Coding Agent、DeepSeek Harness 与 ZCode。
AgentNave 可启动的本地 CLI 智能体。目前支持 Antigravity CLI、Claude Code、CodeBuddy Code、Codex CLI、Grok CLI、Pi Coding Agent、DeepSeek Harness 与 ZCode。Codex CLI 可独立安装,也可由 ChatGPT 桌面应用附带;安装来源不产生新的 Provider 身份。

## 宿主(Host)

Expand Down
7 changes: 6 additions & 1 deletion apps/tray/src/data/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,12 @@ impl Reader {
else {
return Response::error(400, "需要有效的 CLI 名称");
};
match crate::detect::update::update(provider) {
let target =
match crate::detect::update::validate_target(provider, payload.get("target")) {
Ok(target) => target,
Err(error) => return Response::error(400, &error),
};
match crate::detect::update::update(provider, target) {
Ok(state) => Response::json(&state),
Err(error) => Response::error(400, &error),
}
Expand Down
26 changes: 26 additions & 0 deletions apps/tray/src/data/tests/cli_updates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,29 @@ fn cli_updates_routes_require_get_or_authorized_post() {
);
assert_eq!(reader.respond("/settings/member-updates.js").status, 200);
}

#[test]
fn cli_update_target_rejects_desktop_unknown_and_other_providers() {
let dir = tempfile::tempdir().unwrap();
let reader = Reader::new(dir.path().to_owned());
for body in [
json!({"provider":"codex", "target":"desktop"}),
json!({"provider":"codex", "target":"/tmp/codex"}),
json!({"provider":"codex", "target":false}),
json!({"provider":"codex", "target":null}),
json!({"provider":"claude", "target":"standalone"}),
] {
let response = reader.request(
"POST",
"/app/cli-updates/update",
Some("1"),
&serde_json::to_vec(&body).unwrap(),
);
assert_eq!(response.status, 400);
let result: Value = serde_json::from_slice(&response.body).unwrap();
assert!(
result["error"].as_str().unwrap().contains("target"),
"{result}"
);
}
}
98 changes: 86 additions & 12 deletions apps/tray/src/detect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@ use probe::*;
#[cfg(windows)]
pub(crate) use locate::uninstall_icon;
use locate::*;
pub(crate) use locate::{executable, locate};
pub(crate) use locate::{codex_desktop_app, executable, locate};

use std::collections::BTreeMap;
use std::io::{BufRead, BufReader, Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::Mutex;
use std::sync::mpsc;
use std::sync::{LazyLock, Mutex};
use std::thread;
use std::time::{Duration, Instant};

Expand Down Expand Up @@ -76,15 +77,89 @@ const CLAUDE_ALIASES: [&str; 4] = ["fable", "opus", "sonnet", "haiku"];
/// dsh has no listing command; its bundled official models, shipped default first.
const DEEPSEEK_CATALOG: [&str; 2] = ["deepseek-flash", "deepseek-v4-pro"];

static CACHE: Mutex<Option<Value>> = Mutex::new(None);
static CACHE: LazyLock<Mutex<DetectionCache>> = LazyLock::new(|| Mutex::new(Default::default()));

#[derive(Default)]
struct DetectionCache {
all: Option<Value>,
revision: u64,
// At most one successful write per provider, including during the first full probe.
versions: BTreeMap<String, VersionWrite>,
}

struct VersionWrite {
revision: u64,
path: PathBuf,
version: String,
}

impl VersionWrite {
fn apply(&self, provider: &str, all: &mut Value) {
if all[provider]["path"]
.as_str()
.is_some_and(|active| same_path(&self.path, Path::new(active)))
{
all[provider]["version"] = json!(self.version);
}
}
}

impl DetectionCache {
fn remember(&mut self, provider: &str, path: &Path, version: &str) {
self.revision += 1;
let write = VersionWrite {
revision: self.revision,
path: path.to_owned(),
version: version.to_owned(),
};
if let Some(all) = &mut self.all {
write.apply(provider, all);
}
self.versions.insert(provider.to_owned(), write);
}

fn finish(&mut self, started: u64, mut all: Value) -> Value {
for (provider, write) in &self.versions {
if write.revision > started {
write.apply(provider, &mut all);
}
}
self.all = Some(all.clone());
all
}
}

fn same_path(a: &Path, b: &Path) -> bool {
a == b
|| a.canonicalize()
.ok()
.zip(b.canonicalize().ok())
.is_some_and(|(a, b)| a == b)
}

/// Probe results for all providers, kept until `refresh` asks for a new probe. Probes run
/// outside the lock, so concurrent first reads may probe twice; the last result wins.
/// outside the lock; successful updates made during a probe keep their verified versions.
/// A later probe can still observe external version changes.
pub fn detected(refresh: bool) -> Value {
let cache = || CACHE.lock().unwrap_or_else(|poison| poison.into_inner());
if !refresh && let Some(cached) = cache().clone() {
return cached;
}
detected_with(refresh, &CACHE, probe, |all| {
publish_native(&crate::data::data_root(), all);
})
}

fn detected_with(
refresh: bool,
cache: &Mutex<DetectionCache>,
probe: impl Fn(&str) -> Value + Sync,
publish: impl Fn(&Value),
) -> Value {
let cache = || cache.lock().unwrap_or_else(|poison| poison.into_inner());
let started = {
let cache = cache();
if !refresh && let Some(cached) = &cache.all {
return cached.clone();
}
cache.revision
};
let results: Vec<(&str, Value)> = thread::scope(|scope| {
let probes: Vec<_> = PROVIDERS
.iter()
Expand All @@ -102,9 +177,8 @@ pub fn detected(refresh: bool) -> Value {
for (provider, result) in results {
all.insert(provider.to_owned(), result);
}
let all = Value::Object(all);
publish_native(&crate::data::data_root(), &all);
*cache() = Some(all.clone());
let all = cache().finish(started, Value::Object(all));
publish(&all);
all
}

Expand Down Expand Up @@ -164,7 +238,7 @@ fn publish_native(root: &Path, all: &Value) {
/// that requested no model by it until the CLI reports the model it used.
pub fn cached_native(provider: &str) -> Option<String> {
let cache = CACHE.lock().unwrap_or_else(|poison| poison.into_inner());
cache.as_ref()?[provider]["native"]["model"]
cache.all.as_ref()?[provider]["native"]["model"]
.as_str()
.map(str::to_owned)
}
Expand Down
46 changes: 46 additions & 0 deletions apps/tray/src/detect/locate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,52 @@ pub(super) fn find(name: &str) -> Option<PathBuf> {
search_path(&names)
}

/// The same bundled priority as `find`; standalone discovery must not use `find("codex")`.
pub(super) fn codex_desktop() -> Option<PathBuf> {
#[cfg(target_os = "macos")]
return DESKTOP_CODEX
.iter()
.filter_map(|entry| expand(entry))
.find(|p| executable(p));
#[cfg(windows)]
return codex_bundled();
#[cfg(not(any(target_os = "macos", windows)))]
None
}

pub(crate) fn codex_desktop_app(path: &Path) -> Option<&'static str> {
let real = path.canonicalize().unwrap_or_else(|_| path.to_owned());
for path in [path, real.as_path()] {
let text = path.to_string_lossy().replace('\\', "/").to_lowercase();
if text.contains("chatgpt.app/contents/") || text.contains("/openai/chatgpt/bin/") {
return Some("ChatGPT");
}
if text.contains(".app/contents/")
|| text.contains("/.codex/plugins/.plugin-appserver/")
|| text.contains("/openai/codex/bin/")
{
return Some("Codex");
}
}
None
}

pub(super) fn standalone_codex() -> Option<PathBuf> {
let inherited = std::env::var_os("PATH").unwrap_or_default();
standalone_codex_in(std::env::split_paths(&inherited).chain(common_dirs()))
}

pub(super) fn standalone_codex_in(dirs: impl IntoIterator<Item = PathBuf>) -> Option<PathBuf> {
let names: &[&str] = if cfg!(windows) {
&["codex.exe", "codex.cmd", "codex.bat"]
} else {
&["codex"]
};
dirs.into_iter()
.flat_map(|dir| names.iter().map(move |name| dir.join(name)))
.find(|path| executable(path) && codex_desktop_app(path).is_none())
}

pub(super) fn find_on_path(name: &str) -> Option<PathBuf> {
search_path(&[name.to_owned()])
}
Expand Down
114 changes: 86 additions & 28 deletions apps/tray/src/detect/update.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
//! CLI maintenance belongs to the App and follows the detected installation (ADR-0055).
mod codex;
mod install;
mod latest;
mod locks;
#[cfg(test)]
mod tests;

use super::same_path;
use latest::{Channel, cached, newer};
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::{Mutex, MutexGuard};
use std::sync::{LazyLock, Mutex};
use std::time::Duration;

static UPDATING: Mutex<()> = Mutex::new(());
static UPDATING: LazyLock<locks::Updates> = LazyLock::new(locks::Updates::default);
const UPDATE_TIMEOUT: Duration = Duration::from_secs(10 * 60);

#[derive(Debug)]
Expand All @@ -21,6 +24,7 @@ struct Plan {
command: String,
via: &'static str,
channel: Channel,
environment: locks::Environment,
}

pub(crate) fn state() -> Value {
Expand All @@ -40,7 +44,13 @@ fn snapshot(all: &Value, query: impl Fn(&Channel) -> Option<String> + Sync) -> V
let query = &query;
(
provider,
scope.spawn(move || cli_state(provider, &all[provider], query)),
scope.spawn(move || {
if provider == "codex" {
codex::state(&all[provider], &codex::paths(&all[provider]), query)
} else {
cli_state(provider, &all[provider], query)
}
}),
)
})
.collect();
Expand Down Expand Up @@ -73,37 +83,85 @@ fn cli_state(provider: &str, info: &Value, query: impl Fn(&Channel) -> Option<St
"via": plan.as_ref().map(|p| p.via), "command": plan.as_ref().map(|p| &p.command)})
}

fn claim(preview: bool, lock: &Mutex<()>) -> Result<MutexGuard<'_, ()>, String> {
if preview {
/// Only a Codex standalone installation can be selected; paths never come from a request.
pub(crate) fn validate_target(
provider: &str,
target: Option<&Value>,
) -> Result<Option<&'static str>, String> {
match target {
None => Ok(None),
Some(Value::String(target)) if provider == "codex" && target == "standalone" => {
Ok(Some("standalone"))
}
_ => Err("target 仅支持 Codex 的 standalone;桌面内置 CLI 请更新所属桌面应用".into()),
}
}

pub(crate) fn update(provider: &str, target: Option<&str>) -> Result<Value, String> {
if crate::PREVIEW {
return Err("预览版不执行 CLI 更新".into());
}
lock.try_lock()
.map_err(|_| "已有 CLI 正在更新,请稍后重试".into())
let path = match (provider, target) {
("codex", Some("standalone")) => super::locate::standalone_codex(),
(_, None) => super::locate::find(match provider {
"deepseek" => "dsh",
"antigravity" => "agy",
other => other,
}),
_ => return Err("target 仅支持 Codex 的 standalone".into()),
}
.ok_or("未找到此 CLI 安装来源")?;
update_at(
provider,
&path,
crate::PREVIEW,
&UPDATING,
&super::CACHE,
cached,
UPDATE_TIMEOUT,
)?;
Ok(state())
}

pub(crate) fn update(provider: &str) -> Result<Value, String> {
let _guard = claim(crate::PREVIEW, &UPDATING)?;
let before = super::detected(false);
let info = &before[provider];
let plan = info["path"]
.as_str()
.filter(|_| info["installed"] == true)
.and_then(|p| install::identify(provider, Path::new(p)))
.ok_or("无法安全更新此 CLI")?;
let latest = cached(&plan.channel).ok_or("无法查询最新版本")?;
let current = info["version"].as_str().ok_or("无法读取当前版本")?;
if !newer(&latest, current) {
fn read_version(path: &Path) -> Option<String> {
super::run(path, &["--version"])
.ok()
.and_then(|out| super::version_of(&out))
}

fn update_at(
provider: &str,
path: &Path,
preview: bool,
locks: &locks::Updates,
cache: &Mutex<super::DetectionCache>,
query: impl Fn(&Channel) -> Option<String>,
timeout: Duration,
) -> Result<String, String> {
let mut claim = locks.claim(preview, provider)?;
let plan = install::identify(provider, path)
.ok_or("无法安全更新此 CLI;桌面内置 CLI 请更新所属桌面应用")?;
claim.environment(&plan.environment);
// Read and verify this installation directly. A parallel global detection refresh may
// contain another installation or an old version and must never decide update success.
let current = read_version(path).ok_or("无法读取当前版本")?;
let latest = query(&plan.channel).ok_or("无法查询最新版本")?;
if !newer(&latest, &current) {
return Err("没有可用的新版".into());
}
execute(&plan, UPDATE_TIMEOUT)?;
let after = super::detected(true);
verify(
&plan.command,
current,
after[provider]["version"].as_str(),
&latest,
)?;
Ok(snapshot(&after, cached))
execute(&plan, timeout)?;
let after = read_version(path);
verify(&plan.command, &current, after.as_deref(), &latest)?;
let after = after.expect("verified version");
if provider == "codex" {
codex::remember(path, Some(after.clone()));
}
// Also protect in-flight full probes; never wait for model probes or update other paths.
cache
.lock()
.unwrap_or_else(|p| p.into_inner())
.remember(provider, path, &after);
Ok(after)
}

fn verify(command: &str, before: &str, after: Option<&str>, latest: &str) -> Result<(), String> {
Expand Down
Loading
Loading