Skip to content
Open
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ anyhow = "1.0.104"
chrono = { version = "0.4.45", default-features = false, features = ["serde", "clock"] }
dirs = "6"
jsonc-parser = { version = "0.34.0", features = ["cst", "serde_json"] }
libc = "0.2.189"
base64 = "0.22.1"
clap = "4.5.60"
cookie = { version = "0.18.2", default-features = false }
Expand Down
1 change: 1 addition & 0 deletions crates/wizard-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ uuid.workspace = true
wizard-proxy.workspace = true

[target.'cfg(target_os = "macos")'.dependencies]
libc.workspace = true
objc2-app-kit.workspace = true
objc2-foundation.workspace = true
plist.workspace = true
Expand Down
99 changes: 69 additions & 30 deletions crates/wizard-core/src/agents/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ use crate::{
backups::{AgentKind, BackupManager},
changes::{FileChange, FileSnapshot},
},
platform::{command_output, env_var_not_empty, find_executable, is_executable},
platform::{
command_output, daemon_command_status, env_var_not_empty, find_executable, is_executable,
},
prepared_plan::{PreparedAgent, PreparedPlan, PreparedStep},
settings::Settings,
};
use anyhow::{Context as _, Result, ensure};
use core::time::Duration;
use serde::Serialize;
use serde::{Deserialize, Serialize};
use std::{
path::{Path, PathBuf},
process::Stdio,
Expand Down Expand Up @@ -105,49 +107,86 @@ pub fn restore_backup(id: u32) -> Result<()> {
BackupManager::load_backup(AgentKind::Codex, id)
}

/// Call from a Tokio blocking worker. Restart only if the version probe succeeds.
/// Call from a Tokio blocking worker. Restart only if the version probe finds a managed daemon.
/// A restart may interrupt active work; this does not change routing configuration.
pub fn restart_daemon(executable: &Path, codex_home: &Path) -> Result<()> {
let mut probe = tokio::process::Command::new(executable);
probe
.args(["app-server", "daemon", "version"])
.env("CODEX_HOME", codex_home)
.stdout(Stdio::null())
.stderr(Stdio::null());

// TODO: Distinguish an absent daemon from command failures and unsupported versions.
// For now, spawn errors, timeouts and nonzero exit statuses all mean "no daemon".
if !matches!(command_output(probe, Duration::from_secs(15)), Ok(output) if output.status.success())
{
log::info!("Codex daemon restart skipped: version probe did not succeed");
if !managed_daemon_running(executable, codex_home) {
log::info!("Codex daemon restart skipped: no managed daemon is running");
return Ok(());
}

log::info!("Restarting Codex daemon");
// Unlike `daemon restart`, bootstrap also replaces a running updater, which relaunches the
// daemon on updates. It keeps variables later removed from `.env`, and one started by an
// older Wizard stays attributed to Wizard by macOS. Bootstrap resets remote control, though.
let mut command = tokio::process::Command::new(executable);
command
.args(["app-server", "daemon", "restart"])
.env("CODEX_HOME", codex_home)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
.args(["app-server", "daemon", "bootstrap"])
.env("CODEX_HOME", codex_home);
if remote_control_enabled(codex_home)? {
command.arg("--remote-control");
}

// Allow the daemon's maximum five-minute graceful shutdown, plus startup time.
let output = command_output(command, Duration::from_secs(360)).inspect_err(|_| {
log::error!("Codex daemon restart failed: command could not complete");
})?;
if !output.status.success() {
log::error!("Codex daemon restart failed with {}", output.status);
log::info!("Restarting Codex daemon");
// Allow the daemon's maximum five-minute graceful shutdown, plus startup and updater readiness.
let status = daemon_command_status(command, Duration::from_secs(420))
.inspect_err(|_| {
log::error!("Codex daemon restart failed: command could not complete");
})
.context(
"Codex daemon restart failed; run `codex app-server daemon restart` in a terminal",
)?;
if !status.success() {
log::error!("Codex daemon restart failed with {status}");
}
ensure!(
output.status.success(),
"Codex daemon restart failed with {}; run the command manually for diagnostics",
output.status
status.success(),
"Codex daemon restart failed with {status}; run `codex app-server daemon restart` in a terminal"
);
log::info!("Codex daemon restarted successfully");
Ok(())
}

/// Whether `daemon version` reports an app-server that the daemon manages.
fn managed_daemon_running(executable: &Path, codex_home: &Path) -> bool {
#[derive(Deserialize)]
struct Version {
backend: Option<String>,
}

let mut probe = tokio::process::Command::new(executable);
probe
.args(["app-server", "daemon", "version"])
.env("CODEX_HOME", codex_home)
.stdout(Stdio::piped())
.stderr(Stdio::null());

// TODO: Distinguish an absent daemon from command failures and unsupported versions.
// For now, spawn errors, timeouts, nonzero exit statuses and unmanaged servers all mean
// "no daemon".
command_output(probe, Duration::from_secs(15))
.ok()
.filter(|output| output.status.success())
.and_then(|output| serde_json::from_slice::<Version>(&output.stdout).ok())
.is_some_and(|version| version.backend.as_deref() == Some("pid"))
}

fn remote_control_enabled(codex_home: &Path) -> Result<bool> {
let path = codex_home.join("app-server-daemon/settings.json");
let content = match std::fs::read_to_string(&path) {
Ok(content) => content,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) => {
return Err(error).with_context(|| format!("failed to read {}", path.display()));
}
};
let settings: serde_json::Value = serde_json::from_str(&content)
.with_context(|| format!("failed to parse {}", path.display()))?;
Ok(settings
.get("remoteControlEnabled")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false))
}

/// Prepare without writing files or probing the daemon. `token` is ignored when disabled.
pub fn prepare_routing(
mode: RoutingMode,
Expand Down
20 changes: 19 additions & 1 deletion crates/wizard-core/src/platform/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ use core::time::Duration;
use std::{
ffi::OsString,
path::{Path, PathBuf},
process::Output,
process::{ExitStatus, Output, Stdio},
};

#[cfg(target_os = "macos")]
pub(crate) mod macos;
#[cfg(target_os = "macos")]
mod responsibility;

/// Reads a nonempty environment variable, logging only its name when unavailable.
pub(crate) fn env_var_not_empty(name: &str) -> Option<OsString> {
Expand Down Expand Up @@ -179,6 +181,22 @@ pub(crate) fn command_output(
run_command(command, None, timeout)
}

/// Runs a command that starts long-lived background processes, with null stdio and bounded
/// execution time. On macOS, when this process is its own responsible process (the app, not a
/// CLI in a terminal), the command becomes its own, so TCC does not attribute the processes it
/// starts to Wizard. Must be called inside `spawn_blocking`.
pub(crate) fn daemon_command_status(
mut command: tokio::process::Command,
timeout: Duration,
) -> Result<ExitStatus> {
#[cfg(target_os = "macos")]
if responsibility::is_self_responsible() {
return responsibility::command_status_disclaimed(command.as_std(), timeout);
}
command.stdout(Stdio::null()).stderr(Stdio::null());
Ok(command_output(command, timeout)?.status)
}

/// Like `command_output`, but writes `input` to the command's stdin and closes it.
/// Use this to pass secrets instead of command arguments.
pub(crate) fn command_output_with_stdin(
Expand Down
Loading
Loading