Skip to content
Closed
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
7 changes: 6 additions & 1 deletion .github/workflows/local-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,12 @@ jobs:
with:
workspaces: codex-rs
- name: Local crate tests
run: cargo test -p codex-local-cli -p codex-local-models -p codex-local-resume
# --lib: the codex-local bin include!s upstream cli/main.rs, so its bin
# target also compiles upstream's own cli tests (snapshots keyed to `codex`).
# Run only our bin test (the --no-daemon injection table) from it.
run: |
cargo test -p codex-local-cli -p codex-local-models -p codex-local-resume --lib
cargo test -p codex-local-cli --bin codex-local no_daemon
- name: Hook tests (Tests lines in local/FORK_SURFACE.md)
run: |
set -euo pipefail
Expand Down
16 changes: 16 additions & 0 deletions codex-rs/Cargo.lock

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

20 changes: 18 additions & 2 deletions codex-rs/local/cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ doctest = false
[[bin]]
name = "codex-local"
path = "src/main.rs"
# Upstream's in-file unit tests already run in the `codex-cli` package.
test = false
# Upstream's in-file unit tests already run in the `codex-cli` package; only run
# ours here (`cargo test -p codex-local-cli --bin codex-local no_daemon`).

[dependencies]
codex-cli = { path = "../../cli" }
Expand Down Expand Up @@ -127,3 +127,19 @@ windows-sys = { workspace = true, features = [

[dev-dependencies]
tempfile = { workspace = true }
assert_matches = { workspace = true }
codex-app-server-client = { workspace = true }
codex-utils-cargo-bin = { workspace = true }
codex-utils-pty = { workspace = true }
flate2 = { workspace = true }
insta = { workspace = true }
jsonwebtoken = { workspace = true }
predicates = { workspace = true }
pretty_assertions = { workspace = true }
sha2 = { workspace = true }
sqlx = { workspace = true }
tar = { workspace = true }
tokio-tungstenite = { workspace = true }
vt100 = { workspace = true }
wiremock = { workspace = true }
zstd = { workspace = true }
7 changes: 6 additions & 1 deletion codex-rs/local/cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,12 @@ pub fn prepare(args: Vec<OsString>, env: EnvFn, user_home: Option<PathBuf>) -> R
return Ok(Action::SyncConfig);
}
let want_preflight = preflight::needs_preflight(&args, env);
let argv = apply_overrides(args, env)?;
let config_model = std::fs::read_to_string(homes.local.join("config.toml"))
.ok()
.and_then(|t| t.parse::<toml::Table>().ok())
.and_then(|c| c.get("model").and_then(|m| m.as_str()).map(str::to_string))
.or_else(|| Some(DEFAULT_MODEL.to_string()));
let argv = apply_overrides(args, env, config_model)?;
let preflight_root = want_preflight.then(|| effective_root(&argv, &homes, &root));
Ok(Action::Run {
argv,
Expand Down
81 changes: 80 additions & 1 deletion codex-rs/local/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,43 @@

include!("../../../cli/src/main.rs");

/// codex-local registers its resume/model hooks in-process, so the TUI must run
/// in-process too; upstream's app-server daemon would drop them and needs a package
/// codex-local does not ship. Insert `--no-daemon` for plain TUI, `resume` and `fork`
/// unless the user chose `--remote` or already passed the flag. Anything that does
/// not parse (help, errors) is passed through unchanged.
fn inject_no_daemon(argv: Vec<std::ffi::OsString>) -> Vec<std::ffi::OsString> {
let Ok(cli) = MultitoolCli::try_parse_from(argv.clone()) else {
return argv;
};
let (eligible, remote, sub_no_daemon) = match &cli.subcommand {
None => (true, false, false),
Some(Subcommand::Resume(c)) => (
true,
c.remote.remote.is_some(),
c.config_overrides.0.no_daemon,
),
Some(Subcommand::Fork(c)) => (
true,
c.remote.remote.is_some(),
c.config_overrides.0.no_daemon,
),
Some(_) => (false, false, false),
};
if !eligible
|| remote
|| cli.remote.remote.is_some()
|| cli.interactive.no_daemon
|| sub_no_daemon
|| argv.is_empty()
{
return argv;
}
let mut out = argv;
out.insert(1, "--no-daemon".into());
out
}

fn main() -> anyhow::Result<()> {
codex_build_info::initialize!();
let args: Vec<std::ffi::OsString> = std::env::args_os().collect();
Expand Down Expand Up @@ -38,7 +75,49 @@ fn main() -> anyhow::Result<()> {
codex_local_resume::register();
let remote_control_disabled = codex_app_server::take_remote_control_disabled_env();
arg0_dispatch_or_else(move |arg0_paths: Arg0DispatchPaths| async move {
Box::pin(cli_main(argv, arg0_paths, remote_control_disabled)).await?;
Box::pin(cli_main(
inject_no_daemon(argv),
arg0_paths,
remote_control_disabled,
))
.await?;
Ok(())
})
}

#[cfg(test)]
mod no_daemon_tests {
use super::inject_no_daemon;

#[test]
fn injection_decision_table() {
let cases: &[(&[&str], bool)] = &[
(&["x"], true),
(&["x", "fix the bug"], true),
(&["x", "resume", "0192-abc"], true),
(&["x", "resume", "--last"], true),
(&["x", "fork", "--last"], true),
(&["x", "--no-daemon"], false),
(&["x", "resume", "--last", "--no-daemon"], false),
(&["x", "--remote", "ws://h:1"], false),
(&["x", "resume", "--last", "--remote", "ws://h:1"], false),
(&["x", "exec", "hi"], false),
(&["x", "app-server"], false),
(&["x", "app-server", "daemon", "start"], false),
(&["x", "--help"], false),
(&["x", "--bogus"], false),
(&["x", "--", "exec"], true),
];
for (args, injected) in cases {
let argv: Vec<std::ffi::OsString> = args.iter().map(Into::into).collect();
let out = inject_no_daemon(argv.clone());
if *injected {
let mut want = argv;
want.insert(1, "--no-daemon".into());
assert_eq!(out, want, "{args:?}");
} else {
assert_eq!(out, argv, "{args:?}");
}
}
}
}
59 changes: 48 additions & 11 deletions codex-rs/local/cli/src/overrides.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,15 @@ pub(crate) const MAX_RETRIES: u64 = 2;
/// Precedence: flags (`-m`, `--ollama-url`) > `CODEX_LOCAL_MODEL` >
/// `CODEX_LOCAL_OLLAMA_URL` > `OLLAMA_HOST` > config.toml > built-ins. `-m` is left to
/// upstream (it already wins), so the env model is injected only when no model flag is
/// present; config and built-ins need no injection.
pub fn apply_overrides(args: Vec<OsString>, env: EnvFn) -> Result<Vec<OsString>> {
/// present, else the configured model. Both the provider and the model are always
/// injected: upstream's TUI only overrides a resumed session's rollout provider/model
/// (e.g. a stock `openai`/`gpt-*` session) when a CLI layer sets them, and codex-local
/// must always continue on its own provider and model.
pub fn apply_overrides(
args: Vec<OsString>,
env: EnvFn,
config_model: Option<String>,
) -> Result<Vec<OsString>> {
let mut rest = Vec::with_capacity(args.len());
let mut flag_url: Option<String> = None;
let mut has_model_flag = false;
Expand Down Expand Up @@ -58,7 +65,8 @@ pub fn apply_overrides(args: Vec<OsString>, env: EnvFn) -> Result<Vec<OsString>>
out.push("-c".into());
out.push(kv.into());
};
if !has_model_flag && let Some(m) = env("CODEX_LOCAL_MODEL") {
push_c(format!("model_provider=\"{PROVIDER_ID}\""));
if !has_model_flag && let Some(m) = env("CODEX_LOCAL_MODEL").or(config_model) {
push_c(format!("model={}", toml::Value::String(m)));
}
if let Some(root) = root {
Expand All @@ -75,6 +83,8 @@ pub fn apply_overrides(args: Vec<OsString>, env: EnvFn) -> Result<Vec<OsString>>
));
// Ollama fails the built-in server-side web_search tool; also covers old installs.
push_c("web_search=\"disabled\"".to_string());
// No app-server daemon (needs a package we don't ship; also skips our in-process hooks).
push_c("features.daemon_auto_start=false".to_string());
// `CODEX_LOCAL_MAX_AGENTS` beats the seeded `[agents] max_threads` in config.toml.
if let Some(raw) = env("CODEX_LOCAL_MAX_AGENTS") {
match raw.trim().parse::<u64>() {
Expand All @@ -97,7 +107,7 @@ mod tests {
.map(|(_, v)| v.to_string())
};
let a = args.iter().map(OsString::from).collect();
apply_overrides(a, &f)
apply_overrides(a, &f, Some("cfgm".to_string()))
.unwrap()
.iter()
.map(|s| s.to_string_lossy().into_owned())
Expand All @@ -107,18 +117,27 @@ mod tests {
const RQ: &str = "model_providers.codex-local.request_max_retries=2";
const ST: &str = "model_providers.codex-local.stream_max_retries=2";
const WS: &str = "web_search=\"disabled\"";
const MP: &str = "model_provider=\"codex-local\"";
const MD: &str = "model=\"cfgm\"";
const DA: &str = "features.daemon_auto_start=false";
const URL_KEY: &str = "model_providers.codex-local.base_url";

#[test]
fn nothing_set_changes_nothing() {
let want = [
"x",
"-c",
MP,
"-c",
MD,
"-c",
"model_providers.codex-local.request_max_retries=2",
"-c",
"model_providers.codex-local.stream_max_retries=2",
"-c",
"web_search=\"disabled\"",
"-c",
"features.daemon_auto_start=false",
"exec",
"hi",
];
Expand All @@ -129,12 +148,12 @@ mod tests {
fn max_agents_env_valid_invalid_unset() {
let k = "CODEX_LOCAL_MAX_AGENTS";
let ma = "agents.max_threads=8";
assert_eq!(run(&["x", "exec"], &[(k, "8")])[8], ma);
assert_eq!(run(&["x", "exec"], &[(k, "8")]).len(), 10);
assert_eq!(run(&["x", "exec"], &[(k, "8")])[14], ma);
assert_eq!(run(&["x", "exec"], &[(k, "8")]).len(), 16);
for bad in ["0", "-1", "abc", ""] {
assert_eq!(run(&["x", "exec"], &[(k, bad)]).len(), 8);
assert_eq!(run(&["x", "exec"], &[(k, bad)]).len(), 14);
}
assert_eq!(run(&["x", "exec"], &[]).len(), 8);
assert_eq!(run(&["x", "exec"], &[]).len(), 14);
}

#[test]
Expand All @@ -146,37 +165,49 @@ mod tests {
[
"x",
"-c",
MP,
"-c",
MD,
"-c",
&want("http://f:3"),
"-c",
RQ,
"-c",
ST,
"-c",
WS,
"-c",
DA,
"exec"
]
);
assert_eq!(run(&["x", "exec"], &env)[2], want("http://h1:1"));
assert_eq!(run(&["x", "exec"], &env)[6], want("http://h1:1"));
assert_eq!(
run(&["x", "exec"], &[("OLLAMA_HOST", "0.0.0.0")])[2],
run(&["x", "exec"], &[("OLLAMA_HOST", "0.0.0.0")])[6],
want("http://127.0.0.1:11434")
);
}

#[test]
fn model_flag_beats_env_and_dd_is_respected() {
let env = [("CODEX_LOCAL_MODEL", "envm")];
assert_eq!(run(&["x", "exec"], &env)[2], "model=\"envm\"");
assert_eq!(run(&["x", "exec"], &env)[2], MP);
assert_eq!(run(&["x", "exec"], &env)[4], "model=\"envm\"");
assert_eq!(run(&["x", "exec"], &[])[4], "model=\"cfgm\"");
assert_eq!(
run(&["x", "-m", "flagm", "exec"], &env),
[
"x",
"-c",
MP,
"-c",
"model_providers.codex-local.request_max_retries=2",
"-c",
"model_providers.codex-local.stream_max_retries=2",
"-c",
"web_search=\"disabled\"",
"-c",
"features.daemon_auto_start=false",
"-m",
"flagm",
"exec"
Expand All @@ -187,11 +218,17 @@ mod tests {
[
"x",
"-c",
MP,
"-c",
MD,
"-c",
"model_providers.codex-local.request_max_retries=2",
"-c",
"model_providers.codex-local.stream_max_retries=2",
"-c",
"web_search=\"disabled\"",
"-c",
"features.daemon_auto_start=false",
"exec",
"--",
"--ollama-url",
Expand Down
3 changes: 3 additions & 0 deletions codex-rs/local/cli/src/seed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ pub(crate) fn seed_toml(root: &str, stock_home: &str) -> Result<String> {
("multi_agent", Value::Boolean(true)),
// Gates the startup chatgpt.com featured/recommended-plugins fetches.
("plugins", Value::Boolean(false)),
// Run in-process; upstream's daemon needs a package codex-local lacks.
("daemon_auto_start", Value::Boolean(false)),
]),
);
// Ollama can't run the server-side `web_search` tool; use the local MCP server.
Expand Down Expand Up @@ -93,6 +95,7 @@ mod tests {
let v: toml::Value = toml::from_str(&body).unwrap();
assert_eq!(v["agents"]["max_threads"].as_integer(), Some(6));
assert_eq!(v["web_search"].as_str(), Some("disabled"));
assert_eq!(v["features"]["daemon_auto_start"].as_bool(), Some(false));
let ws = &v["mcp_servers"]["web-search"];
assert_eq!(ws["command"].as_str(), Some("uvx"));
assert_eq!(ws["default_tools_approval_mode"].as_str(), Some("approve"));
Expand Down
Loading