use std::ffi::{OsStr, OsString};
use std::io::{self, IsTerminal};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use clap::{CommandFactory, Parser, Subcommand};
use tracing::Instrument;
use uuid::Uuid;
use crate::agent::{Agent, AgentConfig, AgentMode};
use crate::agents_md::AgentsMdBundle;
use crate::events::EventSink;
use crate::mcp::McpRegistry;
use crate::model::{
codex_auth_login, codex_auth_login_access_token, codex_auth_login_api_key,
codex_auth_logout, codex_auth_status, BackendKind, ClientOverrides,
ModelClient, ReasoningContext, ReasoningEffort, ReasoningSummary,
};
use crate::sandbox::{
build_sandbox_spec, parse_mount_spec, SandboxSession, DEFAULT_SANDBOX_IMAGE,
DEFAULT_SANDBOX_WORKDIR,
};
use crate::sessions::{self, SessionSnapshot};
use crate::skills::{self, SkillRegistry};
use crate::store::{self, WorkerContext};
use crate::tui::{self, TuiMetadata, TuiOutcome, UiMode};
use crate::types::Message;
mod args;
mod config;
mod managed_worker;
mod resume;
mod sandbox;
mod upgrade;
use args::*;
use config::*;
use managed_worker::*;
use resume::*;
use sandbox::*;
use upgrade::*;
fn resolve_worker_executable_path() -> Result<PathBuf> {
let executable =
std::env::current_exe().context("failed to determine current sac executable path")?;
let canonical = std::fs::canonicalize(&executable).with_context(|| {
format!(
"failed to canonicalize worker executable {}",
executable.display()
)
})?;
let metadata = std::fs::metadata(&canonical)
.with_context(|| format!("failed to stat worker executable {}", canonical.display()))?;
if !metadata.is_file() {
anyhow::bail!(
"worker executable path is not a file: {}",
canonical.display()
);
}
Ok(canonical)
}
pub async fn run() -> Result<()> {
let cli = parse_cli();
if let ParsedCli::Config(cli) = cli {
run_config_cli(cli)?;
return Ok(());
}
match &cli {
ParsedCli::Run(run_cli) => {
if let Some(dir) = run_cli.directory.as_ref() {
std::env::set_current_dir(dir)?;
}
}
ParsedCli::Resume(resume_cli) => {
if let Some(dir) = resume_cli.directory.as_ref() {
std::env::set_current_dir(dir)?;
}
}
ParsedCli::CodexAuth(_) => {}
ParsedCli::Upgrade(_) => {}
ParsedCli::ManagedWorker(_) => {}
ParsedCli::Config(_) => unreachable!("config handled before terminal checks"),
}
let terminal_available =
io::stdin().is_terminal() && io::stdout().is_terminal() && io::stderr().is_terminal();
if !matches!(
cli,
ParsedCli::ManagedWorker(_) | ParsedCli::CodexAuth(_) | ParsedCli::Upgrade(_)
) && !terminal_available
{
if matches!(&cli, ParsedCli::Resume(resume_cli) if resume_cli.session_id.is_none() && !resume_cli.last)
{
anyhow::bail!("resume requires an interactive terminal");
}
anyhow::bail!("interactive mode requires the TUI; run sac from a terminal");
}
if let ParsedCli::CodexAuth(cli) = cli {
run_codex_auth_cli(cli).await?;
return Ok(());
}
if let ParsedCli::Upgrade(cli) = cli {
run_upgrade_cli(cli).await?;
return Ok(());
}
let app_config = NacConfig::load()?;
let mut run_state = build_run_state(cli, &app_config).await?;
loop {
match run_state {
RunState::ManagedWorker(run_config) => {
let span = tracing::info_span!(
"sac_run",
role = "worker",
pid = std::process::id(),
log_path = ?crate::logging::current_log_path(),
session_id = %run_config.session_id,
thread_name = %run_config.thread_name,
cwd = %run_config.working_directory,
store_path = %run_config.store_path.display(),
sandbox_state = %run_config.sandbox_status,
);
run_managed_worker(run_config).instrument(span).await?;
return Ok(());
}
RunState::Orchestrator {
run_config,
start_in_session_picker,
ui_mode,
} => {
let store_path = run_config.session.store_path();
let session_id = run_config.session.session_id().map(str::to_string);
let workspace_display = run_config.workspace_display.clone();
let sandbox_status = run_config.sandbox_status.clone();
let restored_messages = run_config.agent.messages.clone();
let session_snapshot = run_config.session.into_snapshot();
let agent = run_config.agent;
let client = run_config.client;
let orchestrator_span = tracing::info_span!(
"sac_run",
role = "orchestrator",
pid = std::process::id(),
log_path = ?crate::logging::current_log_path(),
session_id = ?session_id,
cwd = %workspace_display,
store_path = %store_path.display(),
sandbox_state = %sandbox_status,
);
let metadata = TuiMetadata {
cwd: workspace_display,
workspace_host_path: run_config.workspace_host_path,
store_path: store_path.clone(),
model: client.model.clone(),
base_url: client.base_url().to_string(),
backend: client.backend().as_str().to_string(),
reasoning_effort: if matches!(
client.backend(),
BackendKind::OpenAiResponses | BackendKind::ChatGptCodexResponses
) {
client
.reasoning_effort()
.map(|effort| effort.as_str().to_string())
} else {
None
},
session_id,
sandbox_status: run_config.sandbox_status,
agents_md_status: run_config.agents_md_status,
};
match tui::run(
agent,
metadata,
restored_messages,
session_snapshot,
start_in_session_picker,
ui_mode,
)
.instrument(orchestrator_span)
.await?
{
TuiOutcome::Exit => return Ok(()),
TuiOutcome::ResumeSession(session_id) => {
run_state = RunState::Orchestrator {
run_config: build_resume_config_for_session(
store_path,
&session_id,
&app_config,
)
.await?,
start_in_session_picker: false,
ui_mode,
};
continue;
}
}
}
}
}
}
async fn build_run_state(cli: ParsedCli, config: &NacConfig) -> Result<RunState> {
match cli {
ParsedCli::Run(cli) => {
let ui_mode = ui_mode_from_args(&cli.ui, config);
Ok(RunState::Orchestrator {
run_config: build_run_cli_config(cli, config).await?,
start_in_session_picker: false,
ui_mode,
})
}
ParsedCli::ManagedWorker(cli) => Ok(RunState::ManagedWorker(
build_managed_worker_config(cli, config).await?,
)),
ParsedCli::Resume(cli) if cli.session_id.is_none() && !cli.last => {
let ui_mode = ui_mode_from_args(&cli.ui, config);
Ok(RunState::Orchestrator {
run_config: build_resume_picker_config(cli, config).await?,
start_in_session_picker: true,
ui_mode,
})
}
ParsedCli::Resume(cli) => {
let ui_mode = ui_mode_from_args(&cli.ui, config);
Ok(RunState::Orchestrator {
run_config: build_resume_config(cli, config).await?,
start_in_session_picker: false,
ui_mode,
})
}
ParsedCli::Config(_) => unreachable!("config is handled before loading app state"),
ParsedCli::CodexAuth(_) => unreachable!("codex-auth is handled before loading config"),
ParsedCli::Upgrade(_) => unreachable!("upgrade is handled before loading config"),
}
}
fn run_config_cli(cli: ConfigCli) -> Result<()> {
match cli.command.unwrap_or(ConfigCommand::Show) {
ConfigCommand::Path => {
let path = config_path()
.ok_or_else(|| anyhow::anyhow!("unable to resolve sac config path"))?;
println!("{}", path.display());
Ok(())
}
ConfigCommand::LogPath => {
let path = crate::logging::current_log_path()
.ok_or_else(|| anyhow::anyhow!("unable to resolve sac log path"))?;
println!("{}", path.display());
Ok(())
}
ConfigCommand::Logs => {
let logs = crate::logging::list_log_files()?;
if logs.is_empty() {
println!("# No sac log files found");
} else {
for path in logs {
println!("{}", path.display());
}
}
Ok(())
}
ConfigCommand::TailLog => {
let lines = crate::logging::tail_current_log_lines(40)?;
if lines.is_empty() {
println!("# Current log file is empty or missing");
} else {
for line in lines {
println!("{}", line);
}
}
Ok(())
}
ConfigCommand::Doctor => {
let cwd = std::env::current_dir()?;
let loaded_config = NacConfig::load()?;
let config_path = config_path();
let log_path = crate::logging::current_log_path();
let log_count = crate::logging::log_file_count()?;
let tail = crate::logging::tail_current_log_lines(10)?;
let store_path = absolute_store_path(
&cwd,
loaded_config
.storage
.store_path
.clone()
.unwrap_or_else(store::default_store_path),
);
let sessions = sessions::list_sessions(&store_path).unwrap_or_default();
println!("# sac doctor");
println!();
println!("cwd: {}", cwd.display());
println!(
"config_path: {}",
config_path
.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_else(|| "<unavailable>".to_string())
);
println!("store_path: {}", store_path.display());
println!(
"log_path: {}",
log_path
.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_else(|| "<unavailable>".to_string())
);
println!("log_file_count: {}", log_count);
println!("session_count: {}", sessions.len());
println!();
println!("## Recent log lines");
if tail.is_empty() {
println!("<none>");
} else {
for line in tail {
println!("{}", line);
}
}
Ok(())
}
ConfigCommand::Show => {
let path = config_path()
.ok_or_else(|| anyhow::anyhow!("unable to resolve sac config path"))?;
if path.exists() {
let content = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read config {}", path.display()))?;
println!("# Configuration from: {}", path.display());
println!();
print!("{}", content);
if !content.ends_with('\n') {
println!();
}
} else {
println!("# No configuration file found at: {}", path.display());
println!("# Run `sac config init` to create a sample configuration file");
}
Ok(())
}
ConfigCommand::Init => {
let path = config_path()
.ok_or_else(|| anyhow::anyhow!("unable to resolve sac config path"))?;
if path.exists() {
anyhow::bail!(
"configuration file already exists at {}; refusing to overwrite",
path.display()
);
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| {
format!("failed to create config directory {}", parent.display())
})?;
}
std::fs::write(&path, sample_config())
.with_context(|| format!("failed to write config {}", path.display()))?;
println!("Created sample configuration at {}", path.display());
Ok(())
}
ConfigCommand::Reload => {
let path = config_path()
.ok_or_else(|| anyhow::anyhow!("unable to resolve sac config path"))?;
let config = NacConfig::load()?;
println!("Configuration reloaded successfully");
println!("Config path: {}", path.display());
let summary = config_presence_summary(&config);
if summary.is_empty() {
println!("Configured entries: none");
} else {
println!("Configured entries:");
for entry in summary {
println!("- {}", entry);
}
}
Ok(())
}
}
}
async fn run_codex_auth_cli(cli: CodexAuthCli) -> Result<()> {
match cli.command {
Some(CodexAuthCommand::Login { with_api_key: true, .. }) => {
let mut input = String::new();
std::io::stdin().read_line(&mut input)
.context("failed to read API key from stdin")?;
codex_auth_login_api_key(&input)
}
Some(CodexAuthCommand::Login { with_access_token: true, .. }) => {
let mut input = String::new();
std::io::stdin().read_line(&mut input)
.context("failed to read access token from stdin")?;
codex_auth_login_access_token(&input)
}
Some(CodexAuthCommand::Login { headless, .. }) => codex_auth_login(headless).await,
Some(CodexAuthCommand::Status) => codex_auth_status(),
Some(CodexAuthCommand::Logout) => codex_auth_logout().await,
None => {
let mut command = CodexAuthCli::command();
command.print_help()?;
println!();
Ok(())
}
}
}
fn ui_mode_from_args(ui: &UiArgs, config: &NacConfig) -> UiMode {
if ui.compact {
UiMode::Compact
} else if ui.full {
UiMode::Full
} else if config.ui.mode == Some(UiModeConfig::Compact) {
UiMode::Compact
} else {
UiMode::Full
}
}
struct EffectiveSandboxArgs {
sandbox: bool,
no_mount_cwd: bool,
mounts: Vec<String>,
mounts_ro: Vec<String>,
sandbox_image: Option<String>,
sandbox_gpus: Vec<String>,
sandbox_shm_size: Option<String>,
sandbox_session_key: Option<String>,
sandbox_workdir: Option<String>,
explicit_sandbox_config_flags_present: bool,
}
impl SandboxCliArgs for EffectiveSandboxArgs {
fn sandbox_enabled(&self) -> bool {
self.sandbox
}
fn no_mount_cwd(&self) -> bool {
self.no_mount_cwd
}
fn mounts(&self) -> &[String] {
&self.mounts
}
fn mounts_ro(&self) -> &[String] {
&self.mounts_ro
}
fn sandbox_image(&self) -> Option<&str> {
self.sandbox_image.as_deref()
}
fn sandbox_gpus(&self) -> &[String] {
&self.sandbox_gpus
}
fn sandbox_shm_size(&self) -> Option<&String> {
self.sandbox_shm_size.as_ref()
}
fn sandbox_session_key(&self) -> Option<&String> {
self.sandbox_session_key.as_ref()
}
fn sandbox_workdir(&self) -> Option<&String> {
self.sandbox_workdir.as_ref()
}
fn explicit_sandbox_config_flags_present(&self) -> bool {
self.explicit_sandbox_config_flags_present
}
}
fn effective_sandbox_args(cli: SandboxArgs, config: &NacConfig) -> EffectiveSandboxArgs {
let explicit_sandbox_config_flags_present = cli.explicit_sandbox_config_flags_present();
EffectiveSandboxArgs {
sandbox: cli.sandbox,
no_mount_cwd: cli.no_mount_cwd,
mounts: cli.mounts,
mounts_ro: cli.mounts_ro,
sandbox_image: cli.sandbox_image.or_else(|| config.sandbox.image.clone()),
sandbox_gpus: cli.sandbox_gpus,
sandbox_shm_size: cli.sandbox_shm_size,
sandbox_session_key: cli.sandbox_session_key,
sandbox_workdir: cli.sandbox_workdir,
explicit_sandbox_config_flags_present,
}
}
fn env_var(name: &str) -> Option<String> {
std::env::var(name).ok()
}
fn configured_api_key_env(config: &NacConfig) -> Option<String> {
config
.model
.api_key_env
.as_deref()
.filter(|name| !name.trim().is_empty())
.map(str::to_string)
}
fn model_overrides(model: &ModelArgs, config: &NacConfig) -> Result<ClientOverrides> {
Ok(ClientOverrides {
base_url: model
.api_base_url
.clone()
.or_else(|| config.model.base_url.clone())
.or_else(|| env_var("OPENAI_BASE_URL")),
model: model
.api_model
.clone()
.or_else(|| config.model.model.clone())
.or_else(|| env_var("OPENAI_MODEL")),
backend: model.backend.or(config.model.backend),
reasoning_effort: model.reasoning_effort.clone().or_else(|| config.model.reasoning_effort.clone()),
reasoning_summary: model.reasoning_summary.clone().or_else(|| config.model.reasoning_summary.clone()),
reasoning_context: model.reasoning_context.clone().or_else(|| config.model.reasoning_context.clone()),
api_key_env: configured_api_key_env(config),
api_key: config
.model
.api_key
.as_deref()
.filter(|value| !value.trim().is_empty())
.map(str::to_string),
})
}
fn worker_thread_timeout_secs(config: &NacConfig) -> u64 {
config
.worker
.thread_timeout_secs
.unwrap_or(crate::tools::thread::DEFAULT_THREAD_TIMEOUT_SECS)
.max(crate::tools::thread::MIN_THREAD_TIMEOUT_SECS)
}
async fn build_run_cli_config(cli: RunCli, config: &NacConfig) -> Result<OrchestratorRunConfig> {
let client = ModelClient::from_env_with_overrides(model_overrides(&cli.model, config)?)?;
let current_dir = std::env::current_dir()?;
let sandbox_args = effective_sandbox_args(cli.sandbox, config);
let sandbox = build_sandbox_session(&sandbox_args, ¤t_dir).await?;
let agents_md_workspace_dir = effective_agents_md_workspace_dir(¤t_dir, sandbox.as_ref());
let agents_md = AgentsMdBundle::load(agents_md_workspace_dir.as_deref())?;
let working_directory = sandbox
.as_ref()
.map(|session| session.workdir_display())
.unwrap_or_else(current_directory_display);
let workspace_host_path = if let Some(session) = sandbox.as_ref() {
session.host_workdir()
} else {
Some(current_dir.clone())
};
let sandbox_status = sandbox
.as_ref()
.map(|session| session.status_text())
.unwrap_or_else(|| "off".to_string());
let agents_md_message = agents_md.system_message();
let agents_md_status = agents_md.status_text();
let store_path = absolute_store_path(
¤t_dir,
cli.store
.store_path
.or_else(|| config.storage.store_path.clone())
.unwrap_or_else(store::default_store_path),
);
store::initialize(&store_path)?;
let session_id = Uuid::new_v4().to_string();
let worker_executable = resolve_worker_executable_path()?;
tracing::info!(
session_id = %session_id,
cwd = %current_dir.display(),
backend = ?client.backend(),
model = %client.model,
base_url = %client.base_url(),
worker_executable = %worker_executable.display(),
sandbox_status = %sandbox_status,
agents_md_status = %agents_md_status,
"fresh orchestrator config ready"
);
let agent = Agent::with_config(
client.clone(),
AgentConfig {
mode: AgentMode::Orchestrator,
store_path: store_path.clone(),
session_id: Some(session_id.clone()),
worker_executable: Some(worker_executable),
initial_messages: Vec::new(),
thread_name: None,
event_sink: EventSink::none(),
working_directory: working_directory.clone(),
sandbox: sandbox.clone(),
mcp: None,
skills: None,
extra_tool_defs: Vec::new(),
agents_md_message,
thread_timeout_secs: worker_thread_timeout_secs(config),
steering_rx: None,
},
);
let session_snapshot = sessions::new_snapshot(
session_id.clone(),
current_dir,
store_path,
client.model.clone(),
client.base_url().to_string(),
client.backend(),
client.reasoning_effort().cloned(),
sandbox.as_ref().map(|session| session.spec().clone()),
agent.messages.clone(),
);
sessions::create_session(&session_snapshot)?;
Ok(OrchestratorRunConfig {
agent,
client,
session: OrchestratorSession::Active {
session_id,
snapshot: session_snapshot,
},
sandbox_status,
agents_md_status,
workspace_display: working_directory,
workspace_host_path,
})
}
async fn build_managed_worker_config(
cli: ManagedWorkerCli,
config: &NacConfig,
) -> Result<ManagedWorkerRunConfig> {
let client = ModelClient::from_env_with_overrides(model_overrides(&cli.model, config)?)?;
let current_dir = std::env::current_dir()?;
let sandbox_args = effective_sandbox_args(cli.sandbox, config);
let sandbox = build_sandbox_session(&sandbox_args, ¤t_dir).await?;
let agents_md_workspace_dir = effective_agents_md_workspace_dir(¤t_dir, sandbox.as_ref());
let agents_md = AgentsMdBundle::load(agents_md_workspace_dir.as_deref())?;
let skills_workspace_dir = effective_skills_workspace_dir(¤t_dir, sandbox.as_ref());
let working_directory = sandbox
.as_ref()
.map(|session| session.workdir_display())
.unwrap_or_else(current_directory_display);
let sandbox_status = sandbox
.as_ref()
.map(|session| session.status_text())
.unwrap_or_else(|| "off".to_string());
let agents_md_message = agents_md.system_message();
let store_path = absolute_store_path(
¤t_dir,
cli.store
.store_path
.or_else(|| config.storage.store_path.clone())
.unwrap_or_else(store::default_store_path),
);
let mcp = McpRegistry::load(¤t_dir, sandbox.as_ref()).await?;
let skills = SkillRegistry::load(skills_workspace_dir.as_deref(), sandbox.as_ref())?;
let extra_tool_defs = mcp
.as_ref()
.map(|registry| registry.tool_definitions())
.unwrap_or_default();
store::initialize(&store_path)?;
let worker_context = store::load_worker_context(
&store_path,
&cli.dispatch.session_id,
&cli.dispatch.thread_name,
&cli.dispatch.source_threads,
)?;
let agent = Agent::with_config(
client.clone(),
AgentConfig {
mode: AgentMode::Worker,
store_path: store_path.clone(),
session_id: Some(cli.dispatch.session_id.clone()),
worker_executable: None,
initial_messages: build_worker_context_messages(
&cli.dispatch.thread_name,
&worker_context,
),
thread_name: Some(cli.dispatch.thread_name.clone()),
event_sink: EventSink::worker_stderr(),
working_directory: working_directory.clone(),
sandbox,
mcp,
skills,
extra_tool_defs,
agents_md_message,
thread_timeout_secs: worker_thread_timeout_secs(config),
steering_rx: None,
},
);
Ok(ManagedWorkerRunConfig {
agent,
store_path,
session_id: cli.dispatch.session_id,
thread_name: cli.dispatch.thread_name,
action: cli.dispatch.action,
working_directory,
sandbox_status,
})
}
fn effective_agents_md_workspace_dir(
current_dir: &Path,
sandbox: Option<&SandboxSession>,
) -> Option<PathBuf> {
if let Some(sandbox) = sandbox {
return sandbox.host_workdir();
}
Some(current_dir.to_path_buf())
}
fn effective_skills_workspace_dir(
current_dir: &Path,
sandbox: Option<&SandboxSession>,
) -> Option<PathBuf> {
if let Some(sandbox) = sandbox {
return sandbox.host_workdir();
}
Some(current_dir.to_path_buf())
}
fn current_directory_display() -> String {
std::env::current_dir()
.map(|path| path.display().to_string())
.unwrap_or_else(|_| ".".to_string())
}
fn absolute_store_path(cwd: &Path, store_path: PathBuf) -> PathBuf {
if store_path.is_absolute() {
store_path
} else {
cwd.join(store_path)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_env_lock;
fn temp_store_path(label: &str) -> PathBuf {
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time went backwards")
.as_nanos();
std::env::temp_dir()
.join(format!("sac_main_test_{}_{}", label, unique))
.join("store.db")
}
fn default_model_args() -> ModelArgs {
ModelArgs {
backend: None,
reasoning_effort: None,
reasoning_summary: None,
reasoning_context: None,
api_base_url: None,
api_model: None,
}
}
fn default_sandbox_args() -> SandboxArgs {
SandboxArgs {
sandbox: false,
no_mount_cwd: false,
mounts: Vec::new(),
mounts_ro: Vec::new(),
sandbox_image: None,
sandbox_gpus: Vec::new(),
sandbox_shm_size: None,
sandbox_session_key: None,
sandbox_workdir: None,
}
}
fn default_ui_args() -> UiArgs {
UiArgs {
compact: false,
full: false,
}
}
fn restore_env(name: &str, value: Option<OsString>) {
match value {
Some(value) => unsafe { std::env::set_var(name, value) },
None => unsafe { std::env::remove_var(name) },
}
}
#[test]
fn ui_config_sets_default_and_cli_overrides() {
let mut config = NacConfig::default();
config.ui.mode = Some(UiModeConfig::Compact);
assert_eq!(
ui_mode_from_args(&default_ui_args(), &config),
UiMode::Compact
);
assert_eq!(
ui_mode_from_args(
&UiArgs {
compact: false,
full: true,
},
&config,
),
UiMode::Full
);
assert_eq!(
ui_mode_from_args(
&UiArgs {
compact: true,
full: false,
},
&NacConfig::default(),
),
UiMode::Compact
);
}
#[test]
fn model_overrides_prefer_cli_then_config_then_env() {
let _guard = test_env_lock();
let original_base_url = std::env::var_os("OPENAI_BASE_URL");
let original_model = std::env::var_os("OPENAI_MODEL");
unsafe {
std::env::set_var("OPENAI_BASE_URL", "https://env.example/v1");
std::env::set_var("OPENAI_MODEL", "env-model");
}
let mut config = NacConfig::default();
config.model.base_url = Some("https://config.example/v1".to_string());
config.model.model = Some("config-model".to_string());
config.model.backend = Some(BackendKind::OpenAiResponses);
config.model.reasoning_effort = Some(ReasoningEffort::High);
config.model.api_key_env = Some("SAC_TEST_API_KEY".to_string());
let env_overrides = model_overrides(&default_model_args(), &config).unwrap();
assert_eq!(
env_overrides.base_url.as_deref(),
Some("https://config.example/v1")
);
assert_eq!(env_overrides.model.as_deref(), Some("config-model"));
assert_eq!(env_overrides.backend, Some(BackendKind::OpenAiResponses));
assert_eq!(env_overrides.reasoning_effort, Some(ReasoningEffort::High));
assert_eq!(
env_overrides.api_key_env.as_deref(),
Some("SAC_TEST_API_KEY")
);
let cli_overrides = model_overrides(
&ModelArgs {
backend: Some(BackendKind::DeepSeekChat),
reasoning_effort: Some(ReasoningEffort::Low),
reasoning_summary: None,
reasoning_context: None,
api_base_url: Some("https://cli.example/v1".to_string()),
api_model: Some("cli-model".to_string()),
},
&config,
)
.unwrap();
assert_eq!(
cli_overrides.base_url.as_deref(),
Some("https://cli.example/v1")
);
assert_eq!(cli_overrides.model.as_deref(), Some("cli-model"));
assert_eq!(cli_overrides.backend, Some(BackendKind::DeepSeekChat));
assert_eq!(cli_overrides.reasoning_effort, Some(ReasoningEffort::Low));
assert!(cli_overrides.api_key.is_none());
restore_env("OPENAI_BASE_URL", original_base_url);
restore_env("OPENAI_MODEL", original_model);
}
#[test]
fn model_overrides_include_config_api_key() {
let mut config = NacConfig::default();
config.model.api_key = Some("config-secret".to_string());
let overrides = model_overrides(&default_model_args(), &config).unwrap();
assert_eq!(overrides.api_key.as_deref(), Some("config-secret"));
}
#[test]
fn sandbox_image_config_is_default_not_enablement() {
let mut config = NacConfig::default();
config.sandbox.image = Some("custom-image".to_string());
let disabled = effective_sandbox_args(default_sandbox_args(), &config);
assert!(!disabled.sandbox_enabled());
assert!(!disabled.explicit_sandbox_config_flags_present());
assert_eq!(disabled.sandbox_image(), Some("custom-image"));
let mut cli = default_sandbox_args();
cli.sandbox = true;
let enabled = effective_sandbox_args(cli, &config);
assert!(enabled.sandbox_enabled());
assert_eq!(enabled.sandbox_image(), Some("custom-image"));
let mut cli = default_sandbox_args();
cli.sandbox = true;
cli.sandbox_image = Some("cli-image".to_string());
let overridden = effective_sandbox_args(cli, &config);
assert_eq!(overridden.sandbox_image(), Some("cli-image"));
assert!(overridden.explicit_sandbox_config_flags_present());
}
#[test]
fn worker_timeout_reads_config_default() {
let mut config = NacConfig::default();
config.worker.thread_timeout_secs = Some(7_200);
assert_eq!(worker_thread_timeout_secs(&config), 7_200);
config.worker.thread_timeout_secs = Some(10);
assert_eq!(
worker_thread_timeout_secs(&config),
crate::tools::thread::MIN_THREAD_TIMEOUT_SECS
);
}
#[test]
fn sac_config_loads_new_sections_alongside_existing_mcp() {
let _guard = test_env_lock();
let original_sac_home = std::env::var_os("SAC_HOME");
let root = std::env::temp_dir().join(format!(
"sac_config_load_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time went backwards")
.as_nanos()
));
std::fs::create_dir_all(&root).unwrap();
std::fs::write(
root.join("config.toml"),
r#"
[ui]
mode = "compact"
[storage]
store_path = "custom/store.db"
[model]
backend = "openai-responses"
model = "config-model"
base_url = "https://config.example/v1"
reasoning_effort = "high"
api_key_env = "SAC_TEST_API_KEY"
[sandbox]
image = "config-image"
[worker]
thread_timeout_secs = 7200
[mcp_servers.context7]
enabled = true
transport = "streamable_http"
url = "https://mcp.context7.com/mcp"
"#,
)
.unwrap();
unsafe {
std::env::set_var("SAC_HOME", &root);
}
let config = NacConfig::load().unwrap();
assert_eq!(config.ui.mode, Some(UiModeConfig::Compact));
assert_eq!(
config.storage.store_path.as_deref(),
Some(Path::new("custom/store.db"))
);
assert_eq!(config.model.backend, Some(BackendKind::OpenAiResponses));
assert_eq!(config.model.model.as_deref(), Some("config-model"));
assert_eq!(
config.model.base_url.as_deref(),
Some("https://config.example/v1")
);
assert_eq!(config.model.reasoning_effort, Some(ReasoningEffort::High));
assert_eq!(
config.model.api_key_env.as_deref(),
Some("SAC_TEST_API_KEY")
);
assert_eq!(config.sandbox.image.as_deref(), Some("config-image"));
assert_eq!(config.worker.thread_timeout_secs, Some(7_200));
restore_env("SAC_HOME", original_sac_home);
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn sac_config_loads_model_api_key() {
let _guard = test_env_lock();
let original_sac_home = std::env::var_os("SAC_HOME");
let root = std::env::temp_dir().join(format!(
"sac_config_api_key_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time went backwards")
.as_nanos()
));
std::fs::create_dir_all(&root).unwrap();
std::fs::write(
root.join("config.toml"),
"[model]\napi_key = \"config-secret\"\n",
)
.unwrap();
unsafe {
std::env::set_var("SAC_HOME", &root);
}
let config = NacConfig::load().unwrap();
assert_eq!(config.model.api_key.as_deref(), Some("config-secret"));
restore_env("SAC_HOME", original_sac_home);
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn workspace_dir_from_explicit_mount_uses_workspace_guest_mapping() {
let root = std::env::temp_dir().join(format!(
"sac_main_test_workspace_mount_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time went backwards")
.as_nanos()
));
std::fs::create_dir_all(root.join(".git")).unwrap();
let mounts = vec![crate::sandbox::MountSpec {
host: root.clone(),
guest: PathBuf::from(DEFAULT_SANDBOX_WORKDIR),
read_only: false,
}];
let resolved = workspace_dir_from_mounts(&mounts, PathBuf::from(DEFAULT_SANDBOX_WORKDIR));
assert_eq!(resolved.as_deref(), Some(root.as_path()));
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn parse_resume_command_uses_resume_cli() {
let parsed = parse_cli_from(vec![
OsString::from("sac"),
OsString::from("resume"),
OsString::from("session-123"),
]);
match parsed {
ParsedCli::Resume(resume) => {
assert_eq!(resume.session_id.as_deref(), Some("session-123"))
}
ParsedCli::Run(_)
| ParsedCli::ManagedWorker(_)
| ParsedCli::Config(_)
| ParsedCli::CodexAuth(_)
| ParsedCli::Upgrade(_) => {
panic!("expected resume cli")
}
}
}
#[test]
fn parse_resume_command_without_id_uses_resume_picker_cli() {
let parsed = parse_cli_from(vec![OsString::from("sac"), OsString::from("resume")]);
match parsed {
ParsedCli::Resume(resume) => {
assert!(resume.session_id.is_none());
assert!(!resume.last);
}
ParsedCli::Run(_)
| ParsedCli::ManagedWorker(_)
| ParsedCli::Config(_)
| ParsedCli::CodexAuth(_)
| ParsedCli::Upgrade(_) => {
panic!("expected resume cli")
}
}
}
#[test]
fn parse_compact_flag_uses_run_ui_args() {
let parsed = parse_cli_from(vec![OsString::from("sac"), OsString::from("--compact")]);
match parsed {
ParsedCli::Run(run) => assert!(run.ui.compact),
ParsedCli::Resume(_)
| ParsedCli::ManagedWorker(_)
| ParsedCli::Config(_)
| ParsedCli::CodexAuth(_)
| ParsedCli::Upgrade(_) => {
panic!("expected run cli")
}
}
}
#[test]
fn parse_resume_compact_flag_uses_resume_ui_args() {
let parsed = parse_cli_from(vec![
OsString::from("sac"),
OsString::from("resume"),
OsString::from("--compact"),
OsString::from("--last"),
]);
match parsed {
ParsedCli::Resume(resume) => {
assert!(resume.ui.compact);
assert!(resume.last);
}
ParsedCli::Run(_)
| ParsedCli::ManagedWorker(_)
| ParsedCli::Config(_)
| ParsedCli::CodexAuth(_)
| ParsedCli::Upgrade(_) => {
panic!("expected resume cli")
}
}
}
#[test]
fn parse_resume_backend_flag_uses_resume_model_args() {
let parsed = parse_cli_from(vec![
OsString::from("sac"),
OsString::from("resume"),
OsString::from("--backend"),
OsString::from("openai-responses"),
]);
match parsed {
ParsedCli::Resume(resume) => {
assert_eq!(resume.model.backend, Some(BackendKind::OpenAiResponses));
}
ParsedCli::Run(_)
| ParsedCli::ManagedWorker(_)
| ParsedCli::Config(_)
| ParsedCli::CodexAuth(_)
| ParsedCli::Upgrade(_) => {
panic!("expected resume cli")
}
}
}
#[test]
fn parse_hidden_worker_command_uses_managed_worker_cli() {
let parsed = parse_cli_from(vec![
OsString::from("sac"),
OsString::from("__worker"),
OsString::from("--session-id"),
OsString::from("session-123"),
OsString::from("--thread-name"),
OsString::from("impl"),
OsString::from("--action"),
OsString::from("do work"),
OsString::from("--source-thread"),
OsString::from("research"),
]);
match parsed {
ParsedCli::ManagedWorker(worker) => {
assert_eq!(worker.dispatch.session_id, "session-123");
assert_eq!(worker.dispatch.thread_name, "impl");
assert_eq!(worker.dispatch.action, "do work");
assert_eq!(worker.dispatch.source_threads, vec!["research"]);
}
ParsedCli::Run(_)
| ParsedCli::Resume(_)
| ParsedCli::Config(_)
| ParsedCli::CodexAuth(_)
| ParsedCli::Upgrade(_) => {
panic!("expected managed worker cli")
}
}
}
#[test]
fn parse_codex_auth_command_uses_codex_auth_cli() {
let parsed = parse_cli_from(vec![OsString::from("sac"), OsString::from("codex-auth")]);
match parsed {
ParsedCli::CodexAuth(cli) => assert!(cli.command.is_none()),
ParsedCli::Run(_)
| ParsedCli::Resume(_)
| ParsedCli::ManagedWorker(_)
| ParsedCli::Config(_)
| ParsedCli::Upgrade(_) => {
panic!("expected codex-auth cli")
}
}
let parsed = parse_cli_from(vec![
OsString::from("sac"),
OsString::from("codex-auth"),
OsString::from("status"),
]);
match parsed {
ParsedCli::CodexAuth(cli) => {
assert!(matches!(cli.command, Some(CodexAuthCommand::Status)))
}
ParsedCli::Run(_)
| ParsedCli::Resume(_)
| ParsedCli::ManagedWorker(_)
| ParsedCli::Config(_)
| ParsedCli::Upgrade(_) => {
panic!("expected codex-auth cli")
}
}
}
#[test]
fn parse_config_command_uses_config_cli() {
let parsed = parse_cli_from(vec![OsString::from("sac"), OsString::from("config")]);
match parsed {
ParsedCli::Config(cli) => assert!(cli.command.is_none()),
ParsedCli::Run(_)
| ParsedCli::Resume(_)
| ParsedCli::ManagedWorker(_)
| ParsedCli::CodexAuth(_)
| ParsedCli::Upgrade(_) => panic!("expected config cli"),
}
let parsed = parse_cli_from(vec![
OsString::from("sac"),
OsString::from("config"),
OsString::from("reload"),
]);
match parsed {
ParsedCli::Config(cli) => {
assert!(matches!(cli.command, Some(ConfigCommand::Reload)));
}
_ => panic!("expected config cli"),
}
let parsed = parse_cli_from(vec![
OsString::from("sac"),
OsString::from("config"),
OsString::from("log-path"),
]);
match parsed {
ParsedCli::Config(cli) => {
assert!(matches!(cli.command, Some(ConfigCommand::LogPath)));
}
_ => panic!("expected config cli"),
}
let parsed = parse_cli_from(vec![
OsString::from("sac"),
OsString::from("config"),
OsString::from("logs"),
]);
match parsed {
ParsedCli::Config(cli) => {
assert!(matches!(cli.command, Some(ConfigCommand::Logs)));
}
_ => panic!("expected config cli"),
}
let parsed = parse_cli_from(vec![
OsString::from("sac"),
OsString::from("config"),
OsString::from("tail-log"),
]);
match parsed {
ParsedCli::Config(cli) => {
assert!(matches!(cli.command, Some(ConfigCommand::TailLog)));
}
_ => panic!("expected config cli"),
}
let parsed = parse_cli_from(vec![
OsString::from("sac"),
OsString::from("config"),
OsString::from("doctor"),
]);
match parsed {
ParsedCli::Config(cli) => {
assert!(matches!(cli.command, Some(ConfigCommand::Doctor)));
}
_ => panic!("expected config cli"),
}
}
#[test]
fn sample_config_mentions_supported_model_secret_fields() {
let sample = sample_config();
assert!(sample.contains("api_key_env"));
assert!(sample.contains("api_key ="));
}
#[test]
fn config_presence_summary_masks_api_key_value() {
let mut config = NacConfig::default();
config.model.api_key = Some("super-secret".to_string());
config.model.api_key_env = Some("ALT_KEY_ENV".to_string());
let summary = config_presence_summary(&config);
assert!(summary.iter().any(|entry| entry == "model.api_key=[set]"));
assert!(summary
.iter()
.any(|entry| entry == "model.api_key_env=ALT_KEY_ENV"));
assert!(!summary.iter().any(|entry| entry.contains("super-secret")));
}
#[test]
fn run_config_cli_init_show_and_reload_round_trip() {
let _guard = test_env_lock();
let original_sac_home = std::env::var_os("SAC_HOME");
let root = std::env::temp_dir().join(format!(
"sac_config_cli_roundtrip_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time went backwards")
.as_nanos()
));
std::fs::create_dir_all(&root).unwrap();
unsafe {
std::env::set_var("SAC_HOME", &root);
}
run_config_cli(ConfigCli {
command: Some(ConfigCommand::Init),
})
.unwrap();
let path = config_path().unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("[model]"));
assert!(content.contains("api_key_env"));
run_config_cli(ConfigCli {
command: Some(ConfigCommand::Reload),
})
.unwrap();
run_config_cli(ConfigCli {
command: Some(ConfigCommand::Show),
})
.unwrap();
restore_env("SAC_HOME", original_sac_home);
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn current_log_path_uses_sac_home_layout() {
let _guard = test_env_lock();
let original_sac_home = std::env::var_os("SAC_HOME");
let root = std::env::temp_dir().join(format!(
"sac_log_path_cli_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time went backwards")
.as_nanos()
));
std::fs::create_dir_all(&root).unwrap();
unsafe {
std::env::set_var("SAC_HOME", &root);
}
let path = crate::paths::sac_log_path_for_pid(std::process::id()).unwrap();
assert_eq!(
path,
root.join("logs")
.join(format!("sac-{}.log", std::process::id()))
);
restore_env("SAC_HOME", original_sac_home);
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn parse_upgrade_command_uses_upgrade_cli() {
let parsed = parse_cli_from(vec![OsString::from("sac"), OsString::from("upgrade")]);
match parsed {
ParsedCli::Upgrade(cli) => assert!(cli.install_dir.is_none()),
ParsedCli::Run(_)
| ParsedCli::Resume(_)
| ParsedCli::ManagedWorker(_)
| ParsedCli::Config(_)
| ParsedCli::CodexAuth(_) => panic!("expected upgrade cli"),
}
let parsed = parse_cli_from(vec![
OsString::from("sac"),
OsString::from("upgrade"),
OsString::from("--install-dir"),
OsString::from("/tmp/sac-bin"),
]);
match parsed {
ParsedCli::Upgrade(cli) => {
assert_eq!(cli.install_dir.as_deref(), Some(Path::new("/tmp/sac-bin")));
}
ParsedCli::Run(_)
| ParsedCli::Resume(_)
| ParsedCli::ManagedWorker(_)
| ParsedCli::Config(_)
| ParsedCli::CodexAuth(_) => panic!("expected upgrade cli"),
}
}
#[tokio::test]
async fn managed_worker_builds_user_messages_from_self_and_source_threads() {
let _guard = test_env_lock();
let original_api_key = std::env::var("OPENAI_API_KEY").ok();
unsafe {
std::env::set_var("OPENAI_API_KEY", "test_dummy_key");
}
let store_path = temp_store_path("managed_worker_messages");
store::initialize(&store_path).unwrap();
let session_id = "session-msg-order";
store::append_episode(
&store_path,
session_id,
"impl",
"step-1",
"impl retained episode",
)
.unwrap();
store::append_episode(
&store_path,
session_id,
"auth",
"inspect",
"auth latest episode",
)
.unwrap();
store::append_episode(
&store_path,
session_id,
"tests",
"inspect",
"tests latest episode",
)
.unwrap();
let cli = ManagedWorkerCli {
dispatch: WorkerDispatchArgs {
session_id: session_id.to_string(),
thread_name: "impl".to_string(),
action: "implement the next step".to_string(),
source_threads: vec!["auth".to_string(), "tests".to_string()],
},
store: StoreArgs {
store_path: Some(store_path.clone()),
},
model: default_model_args(),
sandbox: default_sandbox_args(),
};
let run_config = build_managed_worker_config(cli, &NacConfig::default())
.await
.unwrap();
assert_eq!(run_config.action, "implement the next step");
let user_messages: Vec<&Message> = run_config
.agent
.messages
.iter()
.filter(|message| matches!(message, Message::User { .. }))
.collect();
assert_eq!(user_messages.len(), 3);
match user_messages[0] {
Message::User { content } => assert!(content.contains("impl retained episode")),
other => panic!("expected self-history user message, got {:?}", other),
}
match user_messages[1] {
Message::User { content } => {
assert!(content.contains("auth latest episode"));
assert!(content.contains("thread \"auth\""));
}
other => panic!("expected first source-thread user message, got {:?}", other),
}
match user_messages[2] {
Message::User { content } => {
assert!(content.contains("tests latest episode"));
assert!(content.contains("thread \"tests\""));
}
other => panic!(
"expected second source-thread user message, got {:?}",
other
),
}
let _ = std::fs::remove_dir_all(store_path.parent().unwrap());
if let Some(key) = original_api_key {
unsafe {
std::env::set_var("OPENAI_API_KEY", key);
}
} else {
unsafe {
std::env::remove_var("OPENAI_API_KEY");
}
}
}
#[test]
fn sandbox_gpu_all_maps_to_nvidia_cdi_device() {
assert_eq!(normalize_gpu_device("all"), "nvidia.com/gpu=all");
assert_eq!(
normalize_gpu_device("nvidia.com/gpu=mig1:0"),
"nvidia.com/gpu=mig1:0"
);
}
#[tokio::test]
async fn resume_config_restores_messages_and_cwd() {
let _guard = test_env_lock();
let original_api_key = std::env::var("OPENAI_API_KEY").ok();
let original_cwd = std::env::current_dir().unwrap();
unsafe {
std::env::set_var("OPENAI_API_KEY", "test_dummy_key");
}
let session_root = std::env::temp_dir().join(format!(
"sac_resume_restore_store_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time went backwards")
.as_nanos()
));
let session_cwd = session_root.join("repo");
std::fs::create_dir_all(&session_cwd).unwrap();
let store_path = session_cwd.join(".sac/store.db");
let snapshot = sessions::new_snapshot(
"resume-session".to_string(),
session_cwd.clone(),
store_path,
"resume-model".to_string(),
"https://api.openai.com/v1".to_string(),
BackendKind::OpenAiResponses,
Some(ReasoningEffort::Xhigh),
None,
vec![
Message::System {
content: "system".to_string(),
},
Message::User {
content: "hello".to_string(),
},
Message::Assistant {
content: Some("world".to_string()),
reasoning_text: Some("hidden thinking".to_string()),
reasoning_details: None,
tool_calls: None,
},
],
);
sessions::create_session(&snapshot).unwrap();
std::env::set_current_dir(std::env::temp_dir()).unwrap();
let run_config = build_resume_config(
ResumeCli {
session_id: Some("resume-session".to_string()),
last: false,
directory: Some(session_cwd.clone()),
store: StoreArgs { store_path: None },
ui: default_ui_args(),
model: default_model_args(),
},
&NacConfig::default(),
)
.await
.unwrap();
assert_eq!(
std::env::current_dir().unwrap().canonicalize().unwrap(),
session_cwd.canonicalize().unwrap(),
"resume should restore the stored cwd"
);
assert_eq!(run_config.session.session_id(), Some("resume-session"));
assert_eq!(run_config.agent.messages.len(), 3);
match &run_config.agent.messages[1] {
Message::User { content } => assert_eq!(content, "hello"),
other => panic!("expected restored user message, got {:?}", other),
}
match &run_config.agent.messages[2] {
Message::Assistant {
content: Some(content),
reasoning_text: Some(reasoning),
..
} => {
assert_eq!(content, "world");
assert_eq!(reasoning, "hidden thinking");
}
other => panic!("expected restored assistant message, got {:?}", other),
}
std::env::set_current_dir(original_cwd).unwrap();
let _ = std::fs::remove_dir_all(session_root);
if let Some(key) = original_api_key {
unsafe {
std::env::set_var("OPENAI_API_KEY", key);
}
} else {
unsafe {
std::env::remove_var("OPENAI_API_KEY");
}
}
}
#[tokio::test]
async fn resume_picker_uses_explicit_backend_override() {
let _guard = test_env_lock();
let original_api_key = std::env::var_os("OPENAI_API_KEY");
let original_base_url = std::env::var_os("OPENAI_BASE_URL");
let original_model = std::env::var_os("OPENAI_MODEL");
let original_cwd = std::env::current_dir().unwrap();
let root = std::env::temp_dir().join(format!(
"sac_resume_picker_backend_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time went backwards")
.as_nanos()
));
std::fs::create_dir_all(&root).unwrap();
unsafe {
std::env::set_var("OPENAI_API_KEY", "test_dummy_key");
std::env::set_var("OPENAI_BASE_URL", "http://localhost:8317/v1");
std::env::set_var("OPENAI_MODEL", "gpt-5.4");
}
std::env::set_current_dir(std::env::temp_dir()).unwrap();
let run_config = build_resume_picker_config(
ResumeCli {
session_id: None,
last: false,
directory: Some(root.clone()),
store: StoreArgs { store_path: None },
ui: default_ui_args(),
model: ModelArgs {
backend: Some(BackendKind::OpenAiResponses),
..default_model_args()
},
},
&NacConfig::default(),
)
.await
.unwrap();
assert_eq!(run_config.client.backend(), BackendKind::OpenAiResponses);
assert_eq!(run_config.client.base_url(), "http://localhost:8317/v1");
assert_eq!(run_config.client.model, "gpt-5.4");
std::env::set_current_dir(original_cwd).unwrap();
let _ = std::fs::remove_dir_all(root);
restore_env("OPENAI_API_KEY", original_api_key);
restore_env("OPENAI_BASE_URL", original_base_url);
restore_env("OPENAI_MODEL", original_model);
}
}