From b6ad2094f88e8f76fa0cbbde63bb4024f1bab077 Mon Sep 17 00:00:00 2001 From: Universe Date: Wed, 14 Oct 2026 00:58:49 +0900 Subject: [PATCH] Refuse an older Codex by its version, and show which Codex doctor found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An older codex earlier on PATH broke every Codex call: it rejected `codex mcp list --json`, so the run was refused with a sentence about MCP servers, while doctor still called the route servable. - Before anything else, the runtime asks `codex --version`. A Codex older than 0.160.1, the version this behaviour was verified on, or one that names no version, is refused for the invocation with a reason that names the version and where it was found (GRIDA_FX_TOOL_CODEX, PATH or the ChatGPT app), never its path. - doctor prints a `program` line per installed program: its path, where it was found, and Codex's version. Codex's routes say `too old (codex ; FX needs 0.160.1+)` or `no version (codex)`, so `doctor ` exits 1 as a live run would refuse. - Finding a program now returns where it was found, and the version comparison is shared with Grok Build's check (`installed::Version`). - spec/providers.md §9.6 first; the guide, llms.txt, the onboarding reference and AGENT_READINESS follow. Part (a) of #73. Preferring a newer copy over an older one on PATH, (b), is a separate spec decision and is not included. --- AGENT_READINESS.md | 2 +- crates/grida-fx-providers/src/codex/mod.rs | 77 ++++++-- .../grida-fx-providers/src/codex/runtime.rs | 47 +++-- crates/grida-fx-providers/src/grok/mod.rs | 18 +- .../grida-fx-providers/src/grok/protocol.rs | 22 +-- crates/grida-fx-providers/src/installed.rs | 95 +++++++++- crates/grida-fx-providers/src/lib.rs | 5 +- crates/grida-fx-providers/src/live.rs | 155 ++++++++++++++--- crates/grida-fx-providers/src/setup.rs | 2 +- crates/grida-fx-providers/tests/codex.rs | 114 ++++++++++-- crates/grida-fx/src/verbs/doctor.rs | 164 +++++++++++++++--- crates/grida-fx/tests/cli.rs | 44 ++++- docs/guide/05-running.md | 20 ++- docs/guide/12-codex.md | 17 +- llms.txt | 3 +- skills/grida-fx/references/onboarding.md | 4 +- spec/providers.md | 8 +- 17 files changed, 641 insertions(+), 156 deletions(-) diff --git a/AGENT_READINESS.md b/AGENT_READINESS.md index a579f4c7..4a94612f 100644 --- a/AGENT_READINESS.md +++ b/AGENT_READINESS.md @@ -91,7 +91,7 @@ narrower target rules. | Discover workflow inputs: `schema ` | JSON Schema for authored inputs | May load author code to resolve a target. | | Validate: `plan ... --check --json` | Expanded graph including problems; nonzero validation status | No FX paid calls; may import code, invoke builders, execute local `at: plan` nodes and write cache results. `--json` alone does not make plan problems a failing exit status. An image step's own request is checked against its route while planning (references need `image_input`, a `WxH` size `exact_size`, a transparent background `alpha`, and pictures within the route's `max_input_pictures`), so an agent learns at $0 that a route cannot take it. | | Inspect planned work: `expand`, `identity`, `price ...` | JSON graph, instance identities, or phased estimates | Planning conditions apply. Unresolved work and estimates are not proof of final work or actual billing. | -| Diagnose: `doctor [target]` | Text prerequisite, tool, route and key-presence diagnostics. The Python line says whether the node host's interpreter is ready, `too old` (FX needs Python 3.11+), has `no grida`, is `NOT FOUND`, or `cannot start`. The Node line after it says the same of the TypeScript node host's Node: ready (with its `@grida/fx` version), `too old` (FX needs Node 24+), `not Node.js 24+` (Bun, Deno), `no @grida/fx`, `NOT FOUND`, or `cannot start`. For that line, doctor starts the host only for a project with a `package.json` (the workflow's or the planning project), since its program may come from an `@grida/fx` above the project; without one, the line gives what Node answered, `(Node )`, or the Node alone. With a target, `uses` lines come first: each route its paid steps use (groups and used workflows included), whether it is servable, and how many steps use it | Let FX inspect credential configuration; agents must not read secret values. Missing keys alone do not make its exit status fail; a route the target uses that cannot be served does (exit 1), so `doctor ` answers "can this run live?" without matching routes by hand. Needs no inputs, and takes no `--routes`: a project that plans with extra route files lists their routes as `not in the catalog`. It does not make provider calls and is not a provider health check. CLI-only: an SDK caller sees a Python older than 3.11, or a Node older than 24, as the engine's refusal sentence. Neither line changes the exit status. | +| Diagnose: `doctor [target]` | Text prerequisite, tool, route and key-presence diagnostics. The Python line says whether the node host's interpreter is ready, `too old` (FX needs Python 3.11+), has `no grida`, is `NOT FOUND`, or `cannot start`. The Node line after it says the same of the TypeScript node host's Node: ready (with its `@grida/fx` version), `too old` (FX needs Node 24+), `not Node.js 24+` (Bun, Deno), `no @grida/fx`, `NOT FOUND`, or `cannot start`. For that line, doctor starts the host only for a project with a `package.json` (the workflow's or the planning project), since its program may come from an `@grida/fx` above the project; without one, the line gives what Node answered, `(Node )`, or the Node alone. A `program` line per installed-program provider names the Codex or Grok Build a live run would start, where it was found and, for Codex, its version; a Codex older than 0.160.1 (or naming none) makes its routes `too old` (`no version`), as a live run refuses it. With a target, `uses` lines come first: each route its paid steps use (groups and used workflows included), whether it is servable, and how many steps use it | Let FX inspect credential configuration; agents must not read secret values. Missing keys alone do not make its exit status fail; a route the target uses that cannot be served does (exit 1), so `doctor ` answers "can this run live?" without matching routes by hand. Needs no inputs, and takes no `--routes`: a project that plans with extra route files lists their routes as `not in the catalog`. It does not make provider calls and is not a provider health check. CLI-only: an SDK caller sees a Python older than 3.11, or a Node older than 24, as the engine's refusal sentence. Neither line changes the exit status. | | Execute: `run ... [--name NAME \| --run ]` | Foreground execution, an early run URL when inspection is available, and final summary | Local code needs no `--live`; paid calls need admission and a ceiling, except cached answers and runs whose every call is on the user's own Codex or Grok Build (`imagegen@codex`, `imagine@grok`, which need no ceiling but spend the user's plan, so still need their go). Choose a name or explicit folder when supervising asynchronously. `--name` refuses collisions; neither naming nor a fresh record forces fresh generation. Project run pages survive completion through the separately started service. `--open` requests browser launch; `--standalone` owns a temporary viewer instead; `--no-view` skips integration and conflicts with `--open`. | | Answer paid calls offline: `run --stand-in FILE#FUNCTION`; SDK `run(…, stand_in=…)` / `run(…, { standIn })` | A run whose paid calls the cache cannot answer are answered by a test's function, at $0 | Never live and runs every phase: `--live` and `--yes-up-to` are usage errors. The file is `.py`, `.ts`, `.mts`, `.js` or `.mjs`, hosted by its language's stand-in host; the SDKs serve the function in the caller's process. Answers pass a provider's checks; what the run stores is kept apart from the project's store, and it resumes only as a stand-in run. It shows what a workflow does with an answer, not how a provider behaves. | | Observe: `observe --snapshot`, `observe --after `, `project `, `inspect --json` | Consistent snapshot, bounded event batches, state projection or summary | Read recorded data without executing workflow code. A step queued for its route's slot is `waiting` (with `waiting_for`), not `running`. Use a precise folder or `inspect /`; `inspect ` chooses the newest-created run, including failures, and refuses ambiguous recorded sources. Keep the emitted folder for observation commands. | diff --git a/crates/grida-fx-providers/src/codex/mod.rs b/crates/grida-fx-providers/src/codex/mod.rs index 17ac011b..772c25ed 100644 --- a/crates/grida-fx-providers/src/codex/mod.rs +++ b/crates/grida-fx-providers/src/codex/mod.rs @@ -14,8 +14,9 @@ //! pictures (`image`, then `references`), and each picture's file: carried, not empty, and //! PNG, JPEG or WebP; //! 4. the runtime's Codex, started if need be ([`runtime`]): no program ([`NOT_INSTALLED`], -//! [`NETWORK_OFF`]), MCP servers that cannot be listed, or a sign-in other than ChatGPT is -//! `Sent::Refused`, for this call and the invocation's others; +//! [`NETWORK_OFF`]), one older than [`MIN_VERSION`] or that names no version, MCP servers that +//! cannot be listed, or a sign-in other than ChatGPT is `Sent::Refused`, for this call and the +//! invocation's others; //! 5. a scratch folder of the call's own holding copies of its pictures (`picture-01.png`, ...), //! and a thread in it (read-only sandbox, no approvals, not persisted; a model provider other //! than OpenAI is refused), then `turn/start` with [`instructions`], which name the pictures @@ -40,7 +41,7 @@ use crate::adapter::{ Adapter, Answer, Billing, CallRequest, RequestAdapter, RouteRef, Sent, Usage, }; use crate::capabilities; -use crate::installed::{self, Picture, failed}; +use crate::installed::{self, Picture, Program, Version, failed}; use crate::registry::Adapters; use crate::setup::Setup; use crate::wire; @@ -81,6 +82,9 @@ pub const PROGRAM_VARIABLE: &str = "GRIDA_FX_TOOL_CODEX"; pub const MACOS_APP_PROGRAM: &str = "/Applications/ChatGPT.app/Contents/Resources/codex-cli/bin/codex"; +/// The oldest Codex this behaviour was verified on. +pub const MIN_VERSION: &str = "0.160.1"; + /// The refusal when no program was found. pub const NOT_INSTALLED: &str = "Codex is not installed: install it with npm install -g \ @openai/codex and sign in with codex login, or set \ @@ -134,8 +138,8 @@ const SETTINGS: [&str; 17] = [ "web_search=\"disabled\"", ]; -/// The deadline of listing the user's MCP servers. -const LIST_DEADLINE: Duration = Duration::from_secs(30); +/// The deadline of asking Codex its version, and of listing the user's MCP servers. +const ASK_DEADLINE: Duration = Duration::from_secs(30); /// How long an answered turn has to end after it is interrupted, for its token usage. const SETTLE: Duration = Duration::from_secs(3); @@ -251,9 +255,50 @@ pub fn register(adapters: &mut Adapters, setup: &Setup) { /// nothing else when it names no executable file; /// - else the first `codex` in an absolute `PATH` entry (empty and relative entries are skipped, /// so a project's own folder never supplies it); -/// - else `app` when it is executable. -pub fn find_program(env: &dyn Fn(&str) -> Option, app: &Path) -> Option { - installed::find_program(env, PROGRAM_VARIABLE, "codex", Some(app.to_path_buf())) +/// - else `app` when it is executable (origin `the ChatGPT app`). +pub fn find_program(env: &dyn Fn(&str) -> Option, app: &Path) -> Option { + let fallback = (app.to_path_buf(), "the ChatGPT app"); + installed::find_program(env, PROGRAM_VARIABLE, "codex", Some(fallback)) +} + +/// Step 4: Codex's version, as `codex --version` names it (`codex-cli 0.160.1`), against +/// [`MIN_VERSION`]; the refusal when it cannot be started. Codex runs in `folder`. +pub fn version<'a>(program: &'a Path, folder: &'a Path) -> BoxFuture<'a, Result> { + Box::pin(async move { + match installed::ask_version(program, folder, &ALSO_WITHHELD, ASK_DEADLINE).await { + Ok(named) => Ok(Version::of(named.as_deref(), MIN_VERSION)), + Err(error) => Err(not_started(&error)), + } + }) +} + +/// Step 4: the refusal of a Codex that [`version`] found older than [`MIN_VERSION`], or that +/// names no version. It says where the program was found, never its path. +pub(super) fn check_version(program: &Program, version: &Version) -> Result<(), String> { + let from = program.origin; + match version { + Version::Supported(_) => Ok(()), + Version::Older { version, minimum } => Err(format!( + "Codex {version} (from {from}) is older than {minimum}: update it, or set \ + {PROGRAM_VARIABLE} to a newer one" + )), + Version::Unnamed => Err(format!( + "Codex (from {from}) did not name its version: update it to {MIN_VERSION} or later, \ + or set {PROGRAM_VARIABLE} to a newer one" + )), + } +} + +/// The refusal when Codex cannot be started. +pub(super) fn not_started(error: &std::io::Error) -> String { + if error.kind() == std::io::ErrorKind::NotFound { + NOT_INSTALLED.into() + } else { + format!( + "{LABEL} cannot start codex: {}", + grida_fx_core::error::io_reason(error) + ) + } } /// Every variable Codex never inherits. @@ -372,7 +417,7 @@ pub(super) async fn mcp_servers(program: &Path, folder: &Path) -> Result output, _ => return Err(refused()), }; @@ -826,9 +871,12 @@ mod tests { (PROGRAM_VARIABLE, shown(&app)), ("PATH", path.clone()), ]); + let find_program = |env: &dyn Fn(&str) -> Option, app: &Path| { + find_program(env, app).map(|program| (program.path, program.origin)) + }; assert_eq!( find_program(&named, Path::new("/missing")), - Some(app.clone()) + Some((app.clone(), PROGRAM_VARIABLE)) ); let missing = env(vec![ (PROGRAM_VARIABLE, "/nonexistent/codex".into()), @@ -840,15 +888,18 @@ mod tests { // A relative name becomes absolute. let relative = env(vec![(PROGRAM_VARIABLE, "some/codex".into())]); let found = find_program(&relative, &app); - assert!(found.is_none_or(|p| p.is_absolute())); + assert!(found.is_none_or(|(p, _)| p.is_absolute())); // Then PATH, absolute entries only; then the app. let blank = env(vec![(PROGRAM_VARIABLE, " ".into()), ("PATH", path.clone())]); - assert_eq!(find_program(&blank, &app), Some(on_path)); + assert_eq!(find_program(&blank, &app), Some((on_path, "PATH"))); let relative_path = env(vec![( "PATH", format!("bin{}", if cfg!(windows) { ";" } else { ":" }), )]); - assert_eq!(find_program(&relative_path, &app), Some(app.clone())); + assert_eq!( + find_program(&relative_path, &app), + Some((app.clone(), "the ChatGPT app")) + ); assert_eq!(find_program(&env(vec![]), &plain), None); } diff --git a/crates/grida-fx-providers/src/codex/runtime.rs b/crates/grida-fx-providers/src/codex/runtime.rs index 94c4ec5b..2c8343ec 100644 --- a/crates/grida-fx-providers/src/codex/runtime.rs +++ b/crates/grida-fx-providers/src/codex/runtime.rs @@ -2,13 +2,14 @@ //! invocation starts on its first Codex call and keeps for the others, and shuts down with the run //! ([`crate::adapter::Runtime`]). //! -//! Starting it, once: list the user's MCP servers to turn them off, start Codex in a process group -//! of its own ([`crate::process_group`]), `initialize`, then `account/read`; a Codex that is not signed -//! in with ChatGPT is refused for the rest of the invocation. Two tasks then serve the -//! connection: a writer, which owns Codex's stdin and writes the lines it is sent, so even a -//! dropped call can ask for its turn to be interrupted without waiting; and a reader, which hands -//! each response to the request waiting for it and each notification to the thread it names, and -//! declines every request Codex sends (an approval, a question). A call has a thread of its own +//! Starting it, once: check its version ([`super::version`]), list the user's MCP servers to turn +//! them off, start Codex in a process group of its own ([`crate::process_group`]), `initialize`, +//! then `account/read`; a Codex that is too old or not signed in with ChatGPT is refused for the +//! rest of the invocation. Two tasks then serve the connection: a writer, which owns Codex's +//! stdin and writes the lines it is sent, so even a dropped call can ask for its turn to be +//! interrupted without waiting; and a reader, which hands each response to the request waiting +//! for it and each notification to the thread it names, and declines every request Codex sends +//! (an approval, a question). A call has a thread of its own //! ([`Connection::start_thread`]); a response to `thread/start` opens the thread's notifications //! before the reader reads on, so none is missed. A response to `turn/start` whose call is gone //! (dropped before Codex named its turn) has the reader interrupt the turn it names @@ -22,11 +23,10 @@ use super::Stop; use super::protocol::{self, Message}; use crate::BoxFuture; use crate::adapter::Runtime; -use crate::installed::{locked, read_line}; +use crate::installed::{Program, locked, read_line}; use crate::process_group::Group; use serde_json::{Value, json}; use std::collections::HashMap; -use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use tokio::io::{AsyncWriteExt, BufReader}; @@ -308,7 +308,7 @@ enum State { /// The runtime (module doc): no Codex runs until a call needs one. pub struct CodexRuntime { /// The program, or the refusal of every call. - program: Result, + program: Result, state: tokio::sync::Mutex, /// The user's usage limit, once a call found it reached: `Some(resets_at)`, the unix time it /// resets when Codex gave it. No call starts while it holds. @@ -316,7 +316,7 @@ pub struct CodexRuntime { } impl CodexRuntime { - pub fn new(program: Result) -> CodexRuntime { + pub fn new(program: Result) -> CodexRuntime { CodexRuntime { program, state: tokio::sync::Mutex::new(State::Idle), @@ -392,30 +392,25 @@ impl Runtime for CodexRuntime { } } -/// Starts a Codex and checks its sign-in (module doc). -async fn open(program: &Path) -> Result { +/// Starts a Codex and checks its version and sign-in (module doc). +async fn open(program: &Program) -> Result { let folder = tempfile::Builder::new() .prefix("grida-fx-codex-") .tempdir() .map_err(|error| Stop::Failed(grida_fx_core::error::io_reason(&error)))?; + let version = super::version(&program.path, folder.path()) + .await + .map_err(Stop::Refused)?; + super::check_version(program, &version).map_err(Stop::Refused)?; + let program = program.path.as_path(); let servers = super::mcp_servers(program, folder.path()) .await .map_err(Stop::Refused)?; let mut args = vec!["app-server".to_string()]; args.extend(super::settings(servers)); - let mut child = match super::command(program, &args, folder.path()).spawn() { - Ok(child) => child, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - return Err(Stop::Refused(super::NOT_INSTALLED.into())); - } - Err(error) => { - return Err(Stop::Refused(format!( - "{} cannot start codex: {}", - super::LABEL, - grida_fx_core::error::io_reason(&error) - ))); - } - }; + let mut child = super::command(program, &args, folder.path()) + .spawn() + .map_err(|error| Stop::Refused(super::not_started(&error)))?; let Some(group) = Group::enlist(&mut child) else { return Err(Stop::Failed("the command is stopping".into())); }; diff --git a/crates/grida-fx-providers/src/grok/mod.rs b/crates/grida-fx-providers/src/grok/mod.rs index f777f618..42904c8d 100644 --- a/crates/grida-fx-providers/src/grok/mod.rs +++ b/crates/grida-fx-providers/src/grok/mod.rs @@ -41,7 +41,7 @@ use crate::adapter::{ Adapter, Answer, Billing, CallRequest, RequestAdapter, RouteRef, Sent, Usage, }; use crate::capabilities; -use crate::installed::{self, Picture, failed}; +use crate::installed::{self, Picture, Program, failed}; use crate::registry::Adapters; use crate::setup::Setup; use crate::wire; @@ -250,9 +250,10 @@ fn installed_program(env: &dyn Fn(&str) -> Option) -> Option { /// else when it names no executable file; /// - else the first `grok` in an absolute `PATH` entry (empty and relative entries are skipped, so /// a project's own folder never supplies it). Never `agent`, which other programs also install; -/// - else [`installed_program`] when it is executable. -pub fn find_program(env: &dyn Fn(&str) -> Option) -> Option { - installed::find_program(env, PROGRAM_VARIABLE, "grok", installed_program(env)) +/// - else [`installed_program`] when it is executable (origin `~/.grok/bin`). +pub fn find_program(env: &dyn Fn(&str) -> Option) -> Option { + let fallback = installed_program(env).map(|path| (path, "~/.grok/bin")); + installed::find_program(env, PROGRAM_VARIABLE, "grok", fallback) } /// Every variable Grok Build never inherits. @@ -787,12 +788,15 @@ mod tests { .map(|(_, v)| v.clone()) } }; + let find_program = |env: &dyn Fn(&str) -> Option| { + find_program(env).map(|program| (program.path, program.origin)) + }; assert_eq!( find_program(&env(vec![ (PROGRAM_VARIABLE, shown(&named)), ("PATH", shown(&bin)) ])), - Some(named.clone()) + Some((named.clone(), PROGRAM_VARIABLE)) ); assert_eq!( find_program(&env(vec![(PROGRAM_VARIABLE, "/nonexistent/grok".into())])), @@ -800,7 +804,7 @@ mod tests { ); assert_eq!( find_program(&env(vec![("PATH", shown(&bin)), ("HOME", shown(&home))])), - Some(on_path) + Some((on_path, "PATH")) ); // `agent` alone is never taken for Grok Build. let only_agent = dir.path().join("only-agent"); @@ -810,7 +814,7 @@ mod tests { ("PATH", shown(&only_agent)), ("HOME", shown(&home)) ])), - Some(installed) + Some((installed, "~/.grok/bin")) ); assert_eq!(find_program(&env(vec![("PATH", shown(&only_agent))])), None); assert_eq!(find_program(&env(vec![("HOME", "relative".into())])), None); diff --git a/crates/grida-fx-providers/src/grok/protocol.rs b/crates/grida-fx-providers/src/grok/protocol.rs index cd94b579..bb0c8232 100644 --- a/crates/grida-fx-providers/src/grok/protocol.rs +++ b/crates/grida-fx-providers/src/grok/protocol.rs @@ -5,7 +5,7 @@ use super::Stop; use crate::adapter::Usage; -use crate::installed; +use crate::installed::{self, Version}; pub(super) use crate::installed::{name, token}; use serde_json::Value; use std::path::{Component, Path, PathBuf}; @@ -39,24 +39,14 @@ pub(super) fn version_of(initialized: &Value) -> Option { installed::version(version) } -/// A version's leading `major.minor.patch` numbers. -fn numbers(version: &str) -> Option<[u64; 3]> { - let core = version.split(['-', '+']).next()?; - let mut parts = core.split('.').map(|part| part.parse::().ok()); - let numbers = [parts.next()??, parts.next()??, parts.next()??]; - parts.next().is_none().then_some(numbers) -} - /// A Grok Build older than [`super::MIN_VERSION`], or one that names no version, is refused. pub(super) fn check_version(version: Option<&str>) -> Result<(), Stop> { - let minimum = numbers(super::MIN_VERSION).expect("a version"); - match version.and_then(|version| numbers(version).map(|n| (version, n))) { - Some((_, found)) if found >= minimum => Ok(()), - Some((version, _)) => Err(Stop::Refused(format!( - "Grok Build {version} is older than {}: run grok update", - super::MIN_VERSION + match Version::of(version, super::MIN_VERSION) { + Version::Supported(_) => Ok(()), + Version::Older { version, minimum } => Err(Stop::Refused(format!( + "Grok Build {version} is older than {minimum}: run grok update" ))), - None => Err(Stop::Refused(format!( + Version::Unnamed => Err(Stop::Refused(format!( "Grok Build did not name its version: run grok update to {} or later", super::MIN_VERSION ))), diff --git a/crates/grida-fx-providers/src/installed.rs b/crates/grida-fx-providers/src/installed.rs index a76ff850..d764e3b4 100644 --- a/crates/grida-fx-providers/src/installed.rs +++ b/crates/grida-fx-providers/src/installed.rs @@ -1,6 +1,7 @@ //! What the adapters of programs the user installed share: Codex and Grok Build -//! (spec/providers.md §9.6, §9.8). Finding the program, starting it without the user's keys, -//! reading its lines, checking a request's pictures, and the bounded words a reason may carry. +//! (spec/providers.md §9.6, §9.8). Finding the program, asking its version, starting it without +//! the user's keys, reading its lines, checking a request's pictures, and the bounded words a +//! reason may carry. use crate::adapter::{CallRequest, RouteRef, Sent}; use crate::wire; @@ -9,6 +10,7 @@ use std::ffi::OsStr; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::{Mutex, MutexGuard}; +use std::time::Duration; use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::process::{ChildStdout, Command}; @@ -22,26 +24,39 @@ pub(crate) fn locked(mutex: &Mutex) -> MutexGuard<'_, T> { .unwrap_or_else(|poisoned| poisoned.into_inner()) } +/// A program the user installed, and where [`find_program`] found it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Program { + /// Absolute, to an executable file. + pub path: PathBuf, + /// Where it was found, in words a reason may carry (never a path): the variable that names + /// it, `PATH`, or the fallback's own (`the ChatGPT app`). + pub origin: &'static str, +} + /// The program, as an absolute path to an executable file: /// - `variable` when set and not blank (relative to the working directory), and nothing else /// when it names no executable file; /// - else the first `name` in an absolute `PATH` entry (empty and relative entries are skipped, so /// a project's own folder never supplies it); -/// - else `fallback` when it is executable. +/// - else `fallback`'s path when it is executable, with its origin. pub(crate) fn find_program( env: &dyn Fn(&str) -> Option, - variable: &str, + variable: &'static str, name: &str, - fallback: Option, -) -> Option { + fallback: Option<(PathBuf, &'static str)>, +) -> Option { if let Some(named) = env(variable).filter(|v| !v.trim().is_empty()) { let named = PathBuf::from(named); - let named = if named.is_absolute() { + let path = if named.is_absolute() { named } else { std::env::current_dir().ok()?.join(named) }; - return executable(&named).then_some(named); + return executable(&path).then_some(Program { + path, + origin: variable, + }); } let on_path = env("PATH").and_then(|path| { std::env::split_paths(&path) @@ -49,7 +64,69 @@ pub(crate) fn find_program( .map(|dir| dir.join(name)) .find(|candidate| executable(candidate)) }); - on_path.or_else(|| fallback.filter(|path| executable(path))) + let found = on_path.map(|path| (path, "PATH")); + let (path, origin) = found.or_else(|| fallback.filter(|(path, _)| executable(path)))?; + Some(Program { path, origin }) +} + +/// A program's version against the oldest its behaviour was verified on. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Version { + /// That one or later. + Supported(String), + Older { + version: String, + minimum: &'static str, + }, + /// The program named none, or none FX reads. + Unnamed, +} + +impl Version { + /// `version` against `minimum`, by their leading `major.minor.patch` numbers. + pub(crate) fn of(version: Option<&str>, minimum: &'static str) -> Version { + let least = numbers(minimum).expect("a version"); + match version.and_then(|version| numbers(version).map(|n| (version, n))) { + Some((version, found)) if found >= least => Version::Supported(version.to_string()), + Some((version, _)) => Version::Older { + version: version.to_string(), + minimum, + }, + None => Version::Unnamed, + } + } +} + +/// A version's leading `major.minor.patch` numbers (a `-` or `+` suffix passed over). +fn numbers(version: &str) -> Option<[u64; 3]> { + let core = version.split(['-', '+']).next()?; + let mut parts = core.split('.').map(|part| part.parse::().ok()); + let numbers = [parts.next()??, parts.next()??, parts.next()??]; + parts.next().is_none().then_some(numbers) +} + +/// What `program --version` names within `deadline`, started as [`command`] starts it with stdin +/// closed: the first word it prints that is a version (`codex-cli 0.160.1`; a leading `v` passed +/// over), as [`version`] keeps it. `None` when it fails, prints none, or runs out of time; an +/// error when it cannot be started. +pub(crate) async fn ask_version( + program: &Path, + folder: &Path, + also: &'static [&'static str], + deadline: Duration, +) -> std::io::Result> { + let mut command = command(program, ["--version"], folder, also); + command.stdin(Stdio::null()); + let child = command.spawn()?; + let output = match tokio::time::timeout(deadline, child.wait_with_output()).await { + Ok(Ok(output)) if output.status.success() => output, + _ => return Ok(None), + }; + Ok(String::from_utf8_lossy(&output.stdout) + .split_whitespace() + .map(|word| word.strip_prefix('v').unwrap_or(word)) + .find(|word| numbers(word).is_some()) + .and_then(version)) } /// A regular file this user may run (on Unix, any execute bit). diff --git a/crates/grida-fx-providers/src/lib.rs b/crates/grida-fx-providers/src/lib.rs index 3fe11361..418013f7 100644 --- a/crates/grida-fx-providers/src/lib.rs +++ b/crates/grida-fx-providers/src/lib.rs @@ -28,7 +28,8 @@ //! - one module per provider: [`openai`], [`openrouter`], [`fal`], [`tripo`], [`elevenlabs`], //! [`codex`] (the user's own Codex, started as a process), [`grok`] (the user's own Grok Build, //! spoken to over [`acp`]), each with `register(&mut Adapters, &Setup)`; -//! - [`process_group`]: the process groups of the runtimes the user installed, and their end; +//! - [`installed`]: what the adapters of programs the user installed share (finding one, its +//! version); [`process_group`]: the process groups of their runtimes, and their end; //! - [`live`]: the adapters of a live run, the only place the default transport is built; //! - [`stand_in`]: the checks a stand-in's answer gets in a stand-in run (spec/protocol.md §5.7): //! its shape, then its route's check, from adapters built keyless over a refusing transport; @@ -52,7 +53,7 @@ pub mod fake; pub mod fal; pub mod gg; pub mod grok; -mod installed; +pub mod installed; pub mod keys; pub mod live; pub mod openai; diff --git a/crates/grida-fx-providers/src/live.rs b/crates/grida-fx-providers/src/live.rs index 619deca1..f947c806 100644 --- a/crates/grida-fx-providers/src/live.rs +++ b/crates/grida-fx-providers/src/live.rs @@ -5,8 +5,8 @@ //! //! [`adapters`] registers every provider's adapters on whatever [`Setup`] it is given, so tests //! build the same registry over a `ReplayTransport` or `NoNetwork`. [`servable`] is what -//! `grida-fx doctor` reports per route, over a registry built on [`offline_setup`]: doctor never -//! constructs anything that could reach the network. +//! `grida-fx doctor` reports per route, over a registry built on [`offline_setup`] and the +//! programs [`installed`] finds: doctor never constructs anything that could reach the network. use crate::BoxFuture; use crate::adapter::{ @@ -14,12 +14,14 @@ use crate::adapter::{ Submitted, }; use crate::clock::SystemClock; +use crate::installed::{Program, Version}; use crate::keys::{KeyName, Keys}; use crate::redact::Redactor; use crate::registry::Adapters; use crate::setup::{Endpoints, Setup}; use crate::transport::http::{HttpConfig, HttpTransport}; use serde_json::Value; +use std::path::Path; use std::sync::Arc; /// The providers this crate serves, in the order `doctor` lists them: Grida Gateway first. @@ -63,6 +65,7 @@ pub fn live_setup(keys: Keys, endpoints: Endpoints, network: Network) -> Result< let env = |name: &str| std::env::var(name).ok(); let codex = codex_program(&env).ok_or_else(|| crate::codex::NOT_INSTALLED.to_string()); let grok = crate::grok::find_program(&env) + .map(|program| program.path) .ok_or_else(|| crate::grok::NOT_INSTALLED.to_string()); ( Arc::new(HttpTransport::new(HttpConfig::default())?), @@ -188,7 +191,7 @@ impl LongJob for Redacting { } /// Whether a route can be sent (doctor's `routes` lines). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum Servable { Yes, /// No adapter of this crate serves the capability on the provider. @@ -197,35 +200,97 @@ pub enum Servable { NoKey(KeyName), /// An adapter serves it through a program that is not installed (`codex`, `grok`). NoProgram(&'static str), + /// An adapter serves it through a program older than a live run starts. + TooOld { + program: &'static str, + version: String, + minimum: &'static str, + }, + /// An adapter serves it through a program that names no version a live run reads. + NoVersion(&'static str), } /// Where the environment finds a program. -type Finder = fn(&dyn Fn(&str) -> Option) -> Option; +type Finder = fn(&dyn Fn(&str) -> Option) -> Option; + +/// How a program is asked its version before a live run starts it: the program, and a folder it +/// may run in. +type Asker = for<'a> fn(&'a Path, &'a Path) -> BoxFuture<'a, Result>; -/// The providers that are programs the user installed, and how each is found. -const PROGRAMS: [(&str, Finder); 2] = [ - ("codex", codex_program), - ("grok", crate::grok::find_program), +/// The providers that are programs the user installed: how each is found, and how it is asked +/// its version before it starts, when it is (Grok Build names its own once started). +const PROGRAMS: [(&str, Finder, Option); 2] = [ + ("codex", codex_program, Some(crate::codex::version)), + ("grok", crate::grok::find_program, None), ]; /// The user's Codex: [`crate::codex::find_program`], with the macOS app's. -fn codex_program(env: &dyn Fn(&str) -> Option) -> Option { - let app = std::path::Path::new(crate::codex::MACOS_APP_PROGRAM); +fn codex_program(env: &dyn Fn(&str) -> Option) -> Option { + let app = Path::new(crate::codex::MACOS_APP_PROGRAM); crate::codex::find_program(env, app) } +/// A program the user installed ([`PROGRAMS`]), as a live run would find it (doctor's `program` +/// lines). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Installed { + /// `codex` or `grok`. + pub name: &'static str, + /// The program, when found. + pub program: Option, + /// Its version, for a program asked before it starts (a program that cannot be started names + /// none). + pub version: Option, +} + +/// Every program the user installed ([`PROGRAMS`]), as the process environment finds it, each +/// asked its version as a live run asks it (in the system's temporary folder). +pub async fn installed() -> Vec { + let env = |name: &str| std::env::var(name).ok(); + let folder = std::env::temp_dir(); + let mut found = Vec::new(); + for (name, find, ask) in PROGRAMS { + let program = find(&env); + let version = match (&program, ask) { + (Some(program), Some(ask)) => Some( + ask(&program.path, &folder) + .await + .unwrap_or(Version::Unnamed), + ), + _ => None, + }; + found.push(Installed { + name, + program, + version, + }); + } + found +} + /// Whether `capability` on `provider` is servable with `keys` by `adapters`. The route of a -/// program the user installed ([`PROGRAMS`]) is servable when the process environment finds the -/// program. -pub fn servable(adapters: &Adapters, keys: &Keys, capability: &str, provider: &str) -> Servable { +/// program the user installed is servable when `installed` holds it found, and of a version a +/// live run starts. +pub fn servable( + adapters: &Adapters, + keys: &Keys, + installed: &[Installed], + capability: &str, + provider: &str, +) -> Servable { if !adapters.serves(capability, provider) { return Servable::NoAdapter; } - if let Some((name, find)) = PROGRAMS.iter().find(|(name, _)| *name == provider) { - let env = |name: &str| std::env::var(name).ok(); - return match find(&env) { - Some(_) => Servable::Yes, - None => Servable::NoProgram(name), + if let Some(program) = installed.iter().find(|program| program.name == provider) { + return match (&program.program, &program.version) { + (None, _) => Servable::NoProgram(program.name), + (Some(_), Some(Version::Older { version, minimum })) => Servable::TooOld { + program: program.name, + version: version.clone(), + minimum, + }, + (Some(_), Some(Version::Unnamed)) => Servable::NoVersion(program.name), + (Some(_), _) => Servable::Yes, }; } match KeyName::of_provider(provider) { @@ -335,21 +400,59 @@ mod tests { fn a_route_without_its_key_is_not_servable() { let keys = Keys::from_pairs(&[(KeyName::Fal, "fal-test-key")]); let adapters = adapters(&setup(keys.clone())); + let servable = |capability, provider| servable(&adapters, &keys, &[], capability, provider); + assert_eq!(servable("video.generate", "fal"), Servable::Yes); assert_eq!( - servable(&adapters, &keys, "video.generate", "fal"), - Servable::Yes + servable("mesh.rig", "tripo"), + Servable::NoKey(KeyName::Tripo) ); + assert_eq!(servable("image.generate", "acme"), Servable::NoAdapter); + assert_eq!(servable("video.generate", "openai"), Servable::NoAdapter); + } + + #[test] + fn a_program_is_servable_when_found_and_of_a_version_a_live_run_starts() { + let keys = Keys::none(); + let adapters = adapters(&setup(keys.clone())); + let found = |name, version| Installed { + name, + program: Some(Program { + path: "/opt/acme/bin/x".into(), + origin: "PATH", + }), + version, + }; + let servable = |installed: &[Installed], provider| { + servable(&adapters, &keys, installed, "image.generate", provider) + }; + let older = Version::Older { + version: "0.150.0".into(), + minimum: "0.160.1", + }; + let codex = |version| [found("codex", Some(version))]; assert_eq!( - servable(&adapters, &keys, "mesh.rig", "tripo"), - Servable::NoKey(KeyName::Tripo) + servable(&codex(Version::Supported("0.160.1".into())), "codex"), + Servable::Yes ); assert_eq!( - servable(&adapters, &keys, "image.generate", "acme"), - Servable::NoAdapter + servable(&codex(older), "codex"), + Servable::TooOld { + program: "codex", + version: "0.150.0".into(), + minimum: "0.160.1" + } ); assert_eq!( - servable(&adapters, &keys, "video.generate", "openai"), - Servable::NoAdapter + servable(&codex(Version::Unnamed), "codex"), + Servable::NoVersion("codex") ); + // Grok Build names its version once started: found is servable. + assert_eq!(servable(&[found("grok", None)], "grok"), Servable::Yes); + let missing = Installed { + name: "grok", + program: None, + version: None, + }; + assert_eq!(servable(&[missing], "grok"), Servable::NoProgram("grok")); } } diff --git a/crates/grida-fx-providers/src/setup.rs b/crates/grida-fx-providers/src/setup.rs index 7929f126..cb35b173 100644 --- a/crates/grida-fx-providers/src/setup.rs +++ b/crates/grida-fx-providers/src/setup.rs @@ -100,7 +100,7 @@ pub struct Setup { pub clock: Arc, /// The user's Codex program, or why there is none (the refusal of every Codex call). Only /// [`crate::live::live_setup`] looks for one. - pub codex: Result, + pub codex: Result, /// The user's Grok Build program, or why there is none (the refusal of every Grok call). Only /// [`crate::live::live_setup`] looks for one. pub grok: Result, diff --git a/crates/grida-fx-providers/tests/codex.rs b/crates/grida-fx-providers/tests/codex.rs index f4f9fd00..19bede92 100644 --- a/crates/grida-fx-providers/tests/codex.rs +++ b/crates/grida-fx-providers/tests/codex.rs @@ -8,6 +8,7 @@ use base64::Engine; use grida_fx_providers::adapter::{Answer, Billing, RequestAdapter, Runtime, Sent}; use grida_fx_providers::codex::{self, CodexImages, CodexRuntime}; +use grida_fx_providers::installed::{Program, Version}; use grida_fx_providers::testing::{self, CallBuilder, TestCall, media}; use serde_json::{Value, json}; use std::future::Future; @@ -15,12 +16,14 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{Duration, Instant}; -/// Counts each start in `starts` (`mcp` or `app-server`), answers `codex mcp list --json` with one -/// enabled and one disabled server, and refuses anything but an app-server whose settings turn -/// off apps, plugins and the enabled server. +/// Counts each start in `starts` (`version`, `mcp` or `app-server`), names Codex 0.160.1 to +/// `codex --version`, answers `codex mcp list --json` with one enabled and one disabled server, +/// and refuses anything but an app-server whose settings turn off apps, plugins and the enabled +/// server. const LISTING: &str = r#"#!/bin/sh here="$(dirname "$0")" case "$*" in + --version) echo version >> "$here/starts"; echo 'codex-cli 0.160.1'; exit 0 ;; *"features.apps=false"*"features.plugins=false"*"mcp list --json"*) echo mcp >> "$here/starts" echo '[{"name":"acme-docs","enabled":true},{"name":"acme-off","enabled":false}]'; exit 0 ;; @@ -174,12 +177,16 @@ fn kite() -> TestCall { call(json!({"prompt": "a red kite"})) } -/// A runtime over `program`, and an adapter over it with these deadlines. +/// A runtime over `program`, found on `PATH`, and an adapter over it with these deadlines. fn images( program: Result, start: u64, whole: u64, ) -> (Arc, CodexImages) { + let program = program.map(|path| Program { + path, + origin: "PATH", + }); let runtime = Arc::new(CodexRuntime::new(program)); let adapter = CodexImages::new(Arc::clone(&runtime)) .with_deadlines(Duration::from_secs(start), Duration::from_secs(whole)); @@ -346,8 +353,8 @@ fn one_codex_serves_every_call_of_an_invocation() { assert!(verified); assert_eq!( fake.starts(), - ["mcp", "app-server"], - "listed once, started once" + ["version", "mcp", "app-server"], + "asked, listed and started once" ); } @@ -478,7 +485,7 @@ fn only_a_chatgpt_sign_in_may_draw_and_a_refusal_holds_for_the_invocation() { assert!(verified); assert_eq!( signed_out.starts(), - ["mcp", "app-server"], + ["version", "mcp", "app-server"], "not asked again" ); assert_eq!( @@ -528,17 +535,104 @@ fi failed("Codex image generation failed: Codex exited") ); assert!(matches!(sent.1, Sent::Answered(_)), "{:?}", sent.1); - assert_eq!(fake.starts(), ["mcp", "app-server", "mcp", "app-server"]); + assert_eq!( + fake.starts(), + [ + "version", + "mcp", + "app-server", + "version", + "mcp", + "app-server" + ] + ); } #[test] fn servers_that_cannot_be_listed_are_a_refusal() { + let unlisted = + "#!/bin/sh\ncase \"$1\" in --version) echo 'codex-cli 0.160.1' ;; *) exit 1 ;; esac\n"; assert_eq!( - send(&fake("#!/bin/sh\nexit 1\n")), + send(&fake(unlisted)), refused("Codex's MCP servers could not be listed, so they cannot be turned off") ); } +/// A fake Codex that prints `printed` and exits with `status` when asked its version, and counts +/// every other start. +fn versioned(printed: &str, status: u8) -> Fake { + fake(&format!( + "#!/bin/sh\ncase \"$1\" in --version) echo '{printed}'; exit {status} ;; esac\n\ + echo started >> \"$(dirname \"$0\")/starts\"\nexit 1\n" + )) +} + +#[test] +fn codex_names_its_version_as_it_prints_it() { + let tokio = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let folder = tempfile::tempdir().unwrap(); + let version = |fake: &Fake| tokio.block_on(codex::version(&fake.program, folder.path())); + let supported = |v: &str| Ok(Version::Supported(v.into())); + for (printed, expected) in [ + ("codex-cli 0.160.1", supported("0.160.1")), + ( + "codex-cli 0.162.0-alpha.17.2", + supported("0.162.0-alpha.17.2"), + ), + ("v1.0.0", supported("1.0.0")), + ( + "codex-cli 0.150.0", + Ok(Version::Older { + version: "0.150.0".into(), + minimum: codex::MIN_VERSION, + }), + ), + ("codex-cli 0.150", Ok(Version::Unnamed)), + ("Welcome to Codex", Ok(Version::Unnamed)), + ] { + assert_eq!(version(&versioned(printed, 0)), expected, "{printed}"); + } + assert_eq!( + version(&versioned("codex-cli 0.160.1", 2)), + Ok(Version::Unnamed), + "a failure names none" + ); + let missing = Path::new("/nonexistent/codex"); + assert_eq!( + tokio.block_on(codex::version(missing, folder.path())), + Err(codex::NOT_INSTALLED.to_string()) + ); +} + +#[test] +fn an_older_codex_is_refused_for_the_invocation_naming_where_it_was_found() { + let old = versioned("codex-cli 0.150.0", 0); + let (sent, verified) = on_one_runtime(Ok(old.program.clone()), 20, 30, |adapter| async move { + let first = adapter.send(&kite().call).await; + assert_eq!(adapter.send(&kite().call).await, first); + first + }); + assert!(verified); + assert_eq!( + sent, + refused( + "Codex 0.150.0 (from PATH) is older than 0.160.1: update it, or set \ + GRIDA_FX_TOOL_CODEX to a newer one" + ) + ); + assert!(old.starts().is_empty(), "nothing else was started"); + assert_eq!( + send(&versioned("Welcome to Codex", 0)), + refused( + "Codex (from PATH) did not name its version: update it to 0.160.1 or later, or set \ + GRIDA_FX_TOOL_CODEX to a newer one" + ) + ); +} + #[test] fn a_usage_limit_ends_the_call_with_its_reset_time() { let limited = item( @@ -906,7 +1000,7 @@ fn what_codex_cannot_honour_is_refused_before_it_starts() { refused(codex::NO_EXACT_SIZE), refused("references[0] has no bytes to send"), refused("image.generate takes no member seed"), - refused("Codex's MCP servers could not be listed, so they cannot be turned off"), + refused(codex::NOT_INSTALLED), refused("Codex image edits take no mask"), refused("Codex takes at most 5 input pictures"), refused("references[1] is image/gif; Codex takes PNG, JPEG or WebP"), diff --git a/crates/grida-fx/src/verbs/doctor.rs b/crates/grida-fx/src/verbs/doctor.rs index ce728933..f9f48047 100644 --- a/crates/grida-fx/src/verbs/doctor.rs +++ b/crates/grida-fx/src/verbs/doctor.rs @@ -68,9 +68,16 @@ //! (environment)`, `… present (.env)` or `… missing`. A missing key alone does not change the //! exit status, unless a route the target uses needs it (its `uses` line is then not //! servable). A key's value is never printed; +//! - one line per program the user installed (Codex, then Grok Build), found and asked its +//! version once (`live::installed`), as a live run would: `program (, +//! )`, `` being where it was found (`GRIDA_FX_TOOL_CODEX`, `PATH`, `the +//! ChatGPT app`; `GRIDA_FX_TOOL_GROK`, `PATH`, `~/.grok/bin`), and `` what Codex names +//! (`no version` when it names none; Grok Build names its own once started, so its line has +//! none); else `program not installed`. These lines do not set the exit status; //! - one line per route of the planning project's catalog (the built-in table, then `fx.yaml` //! `route_tables`), sorted by capability, then id: `route -//! servable`, `… no key ()`, `… not installed (codex)` (or `(grok)`) or `… no adapter`, as +//! servable`, `… no key ()`, `… not installed (codex)` (or `(grok)`), `… too old +//! (codex ; FX needs 0.160.1+)`, `… no version (codex)` or `… no adapter`, as //! `live::servable` answers over every provider's adapters built on `live::offline_setup`. use crate::cli::DoctorArgs; @@ -84,8 +91,9 @@ use grida_fx_core::project::load_target; use grida_fx_core::registry::{Registry, RegistryError, Resolved}; use grida_fx_core::routes::{Route, RouteTable, load_catalog}; use grida_fx_protocol::DescribeParams; +use grida_fx_providers::installed::Version; use grida_fx_providers::keys::{ALLOWED, Environment, KeySource}; -use grida_fx_providers::live::{self, Servable}; +use grida_fx_providers::live::{self, Installed, Servable}; use grida_fx_providers::{Endpoints, KeyName, Keys}; use grida_fx_runtime::host::locate::{ MINIMUM_NODE, MINIMUM_PYTHON, NO_SDK, Probe, node_runtime, python_interpreter, @@ -159,7 +167,12 @@ pub fn run(args: &DoctorArgs) -> Result { &mut Vec::new(), &mut used, ); - let (uses, unservable) = uses_lines(&used, &providers.catalog, &providers.keys); + let (uses, unservable) = uses_lines( + &used, + &providers.catalog, + &providers.keys, + &providers.installed, + ); for line in &uses { print_line(line); } @@ -257,7 +270,12 @@ fn count_route( /// One `uses` line per route in `used` (module doc, "The target's routes"), and whether any of /// them cannot be served. -fn uses_lines(used: &UsedRoutes, catalog: &RouteTable, keys: &Keys) -> (Vec, bool) { +fn uses_lines( + used: &UsedRoutes, + catalog: &RouteTable, + keys: &Keys, + installed: &[Installed], +) -> (Vec, bool) { let adapters = live::adapters(&live::offline_setup(keys.clone())); let mut unservable = false; let lines = used @@ -267,11 +285,10 @@ fn uses_lines(used: &UsedRoutes, catalog: &RouteTable, keys: &Keys) -> (Vec ("no route".to_string(), false), Some(id) => match catalog.resolve(capability, id) { Ok(route) => { - let servable = live::servable(&adapters, keys, capability, &route.provider); - ( - format!("{id} {}", servable_text(servable)), - servable == Servable::Yes, - ) + let servable = + live::servable(&adapters, keys, installed, capability, &route.provider); + let served = servable == Servable::Yes; + (format!("{id} {}", servable_text(servable)), served) } Err(_) => (format!("{id} not in the catalog"), false), }, @@ -291,6 +308,12 @@ fn servable_text(servable: Servable) -> String { Servable::NoKey(key) => format!("no key ({})", key.variable()), Servable::NoAdapter => "no adapter".to_string(), Servable::NoProgram(program) => format!("not installed ({program})"), + Servable::TooOld { + program, + version, + minimum, + } => format!("too old ({program} {version}; FX needs {minimum}+)"), + Servable::NoVersion(program) => format!("no version ({program})"), } } @@ -300,8 +323,10 @@ struct Providers { lines: Vec, /// The `.env` or a base URL was refused: a live run would not start. refused: bool, - /// The keys a live run would have, and the planning project's catalog. + /// The keys a live run would have, the programs it would find, and the planning project's + /// catalog. keys: Keys, + installed: Vec, catalog: RouteTable, } @@ -324,12 +349,19 @@ impl Providers { } let refused = !lines.is_empty(); lines.extend(key_lines(&keys)); + let installed = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| Error::io("the programs' versions", &error))? + .block_on(live::installed()); + lines.extend(program_lines(&installed)); let catalog = load_catalog(project, &crate::engine::builtin_routes()?, &[])?; - lines.extend(route_lines(&catalog, &keys)); + lines.extend(route_lines(&catalog, &keys, &installed)); Ok(Providers { lines, refused, keys, + installed, catalog, }) } @@ -382,8 +414,32 @@ fn key_lines(keys: &Keys) -> Vec { .collect() } +/// One `program` line per program the user installed (module doc, "Providers"). +fn program_lines(installed: &[Installed]) -> Vec { + installed + .iter() + .map(|found| { + let Some(program) = &found.program else { + return labelled("program", &format!("{} not installed", found.name)); + }; + let version = match &found.version { + None => String::new(), + Some(Version::Supported(version) | Version::Older { version, .. }) => { + format!(", {version}") + } + Some(Version::Unnamed) => ", no version".to_string(), + }; + let (name, path) = (found.name, program.path.display()); + labelled( + "program", + &format!("{name} {path} ({}{version})", program.origin), + ) + }) + .collect() +} + /// One `route` line per route of `catalog`, sorted by capability, then id. -fn route_lines(catalog: &RouteTable, keys: &Keys) -> Vec { +fn route_lines(catalog: &RouteTable, keys: &Keys, installed: &[Installed]) -> Vec { let adapters = live::adapters(&live::offline_setup(keys.clone())); let mut routes: Vec<(&str, String, &Route)> = catalog .entries @@ -394,7 +450,8 @@ fn route_lines(catalog: &RouteTable, keys: &Keys) -> Vec { routes .into_iter() .map(|(capability, id, route)| { - let state = servable_text(live::servable(&adapters, keys, capability, &route.provider)); + let servable = live::servable(&adapters, keys, installed, capability, &route.provider); + let state = servable_text(servable); labelled("route", &format!("{capability} {id} {state}")) }) .collect() @@ -667,7 +724,7 @@ mod tests { ]); let value = "fx-made-up-fal-value-41d7"; let keys = Keys::from_pairs(&[(KeyName::Fal, value)]); - let lines = route_lines(&catalog, &keys); + let lines = route_lines(&catalog, &keys, &[]); assert_eq!( lines, [ @@ -682,13 +739,60 @@ mod tests { assert!(lines.iter().all(|line| !line.contains(value))); } + /// Codex found on `PATH` naming `version`, and no Grok Build. + fn installed(version: Version) -> Vec { + let program = grida_fx_providers::installed::Program { + path: "/opt/acme/bin/codex".into(), + origin: "PATH", + }; + vec![ + Installed { + name: "codex", + program: Some(program), + version: Some(version), + }, + Installed { + name: "grok", + program: None, + version: None, + }, + ] + } + + #[test] + fn program_lines_say_where_each_program_was_found_and_its_version() { + let older = Version::Older { + version: "0.150.0".into(), + minimum: "0.160.1", + }; + assert_eq!( + program_lines(&installed(older)), + [ + "program codex /opt/acme/bin/codex (PATH, 0.150.0)", + "program grok not installed", + ] + ); + let mut found = installed(Version::Unnamed); + assert_eq!( + program_lines(&found)[0], + "program codex /opt/acme/bin/codex (PATH, no version)" + ); + // A program asked nothing before it starts shows none. + found[0].version = None; + assert_eq!( + program_lines(&found)[0], + "program codex /opt/acme/bin/codex (PATH)" + ); + } + #[test] fn every_built_in_route_has_a_line() { let catalog = crate::engine::builtin_routes().unwrap(); - let lines = route_lines(&catalog, &Keys::none()); + let supported = installed(Version::Supported("0.160.1".into())); + let lines = route_lines(&catalog, &Keys::none(), &supported); assert_eq!(lines.len(), catalog.entries.len()); - // The Codex and Grok Build routes need no key; whether they are servable depends on this - // machine. + // The Codex and Grok Build routes need no key: they need the program, and Codex one + // that is recent enough. let (grok, lines_but_grok): (Vec<&String>, Vec<&String>) = lines.iter().partition(|line| line.contains("@grok ")); let (codex, keyed): (Vec<&String>, Vec<&String>) = lines_but_grok @@ -697,8 +801,7 @@ mod tests { assert_eq!(grok.len(), 2, "image.edit and image.generate"); for line in grok { assert!( - line.ends_with("imagine@grok servable") - || line.ends_with("imagine@grok not installed (grok)"), + line.ends_with("imagine@grok not installed (grok)"), "{line}" ); } @@ -708,10 +811,20 @@ mod tests { ); assert_eq!(codex.len(), 2, "image.edit and image.generate"); for line in codex { + assert!(line.ends_with("imagegen@codex servable"), "{line}"); + } + let older = Version::Older { + version: "0.150.0".into(), + minimum: "0.160.1", + }; + for (version, state) in [ + (older, "too old (codex 0.150.0; FX needs 0.160.1+)"), + (Version::Unnamed, "no version (codex)"), + ] { + let lines = route_lines(&catalog, &Keys::none(), &installed(version)); assert!( - line.ends_with("imagegen@codex servable") - || line.ends_with("imagegen@codex not installed (codex)"), - "{line}" + lines.contains(&format!("route image.edit imagegen@codex {state}")), + "{lines:#?}" ); } let mut sorted = lines.clone(); @@ -749,6 +862,7 @@ mod tests { ]), &catalog, &keys, + &[], ); assert_eq!( lines, @@ -765,13 +879,17 @@ mod tests { &used(&[("background.remove", Some("bg@fal"), 1)]), &catalog, &keys, + &[], ); assert_eq!( lines, ["uses background.remove bg@fal servable (1 step)"] ); assert!(!unservable); - assert_eq!(uses_lines(&used(&[]), &catalog, &keys), (Vec::new(), false)); + assert_eq!( + uses_lines(&used(&[]), &catalog, &keys, &[]), + (Vec::new(), false) + ); } #[test] diff --git a/crates/grida-fx/tests/cli.rs b/crates/grida-fx/tests/cli.rs index 2a9415f5..2f6d8635 100644 --- a/crates/grida-fx/tests/cli.rs +++ b/crates/grida-fx/tests/cli.rs @@ -335,8 +335,9 @@ fn integrated_doctor_without_a_target() { assert_eq!(status(&output), 0, "{}", stderr(&output)); let text = stdout(&output); let lines: Vec<&str> = text.lines().collect(); - // engine, python, node, a key per provider, a route per built-in route. - assert_eq!(lines.len(), 3 + 6 + 29, "{text}"); + // engine, python, node, a key per provider, a program per installed provider, a route per + // built-in route. + assert_eq!(lines.len(), 3 + 6 + 2 + 29, "{text}"); assert_eq!( lines[0], format!("engine grida-fx {}", env!("CARGO_PKG_VERSION")) @@ -360,7 +361,14 @@ fn integrated_doctor_without_a_target() { "key ELEVENLABS_API_KEY missing", ] ); - let routes = &lines[9..]; + assert_eq!( + lines[9..11], + [ + "program codex not installed", + "program grok not installed" + ] + ); + let routes = &lines[11..]; assert!(routes.iter().all(|l| l.starts_with("route ")), "{text}"); let codex = [ "route image.edit imagegen@codex not installed (codex)", @@ -575,15 +583,39 @@ fn integrated_doctor_puts_the_targets_routes_first() { text.contains("\nroute image.generate imagegen@codex not installed (codex)\n"), "{text}" ); - // With Codex installed (doctor only looks for it), every route the target uses is servable. + // With Codex installed (doctor only asks its version), every route the target uses is + // servable. let codex = root.join("codex"); - grida_fx_providers::testing::write_program(&codex, "#!/bin/sh\nexit 0\n"); + let version = |printed: &str| { + grida_fx_providers::testing::write_program( + &codex, + &format!("#!/bin/sh\n[ \"$1\" = --version ] && echo '{printed}'\nexit 0\n"), + ); + }; + version("codex-cli 0.160.1"); let output = doctor(&codex); assert_eq!(status(&output), 0, "{}", stderr(&output)); + let text = stdout(&output); assert_eq!( - stdout(&output).lines().nth(3), + text.lines().nth(3), Some("uses image.generate imagegen@codex servable (3 steps)") ); + let shown = format!( + "\nprogram codex {} (GRIDA_FX_TOOL_CODEX, 0.160.1)\n", + codex.display() + ); + assert!(text.contains(&shown), "{text}"); + // An older Codex, which a live run refuses, is not. + version("codex-cli 0.150.0"); + let output = doctor(&codex); + assert_eq!(status(&output), 1, "{}", stderr(&output)); + assert_eq!( + stdout(&output).lines().nth(3), + Some( + "uses image.generate imagegen@codex too old (codex 0.150.0; FX needs 0.160.1+) \ + (3 steps)" + ) + ); } /// A project whose workflow uses a Python node, and an interpreter at `python` that answers the diff --git a/docs/guide/05-running.md b/docs/guide/05-running.md index 57c83d9f..b4456492 100644 --- a/docs/guide/05-running.md +++ b/docs/guide/05-running.md @@ -177,9 +177,9 @@ sent, and the step fails with a message that names `GRIDA_FX_NETWORK`, for nothi paths use it. **Checking keys and routes.** `grida fx doctor` prints, after the engine, Python and Node lines, -where each key comes from and which routes a live run could serve, for the project you run from -(the built-in table and its `route_tables`); with a target, the tools and node types the workflow -needs follow: +where each key comes from, which Codex and Grok Build it found, and which routes a live run could +serve, for the project you run from (the built-in table and its `route_tables`); with a target, the +tools and node types the workflow needs follow: ``` engine grida-fx 0.6.0 @@ -191,6 +191,8 @@ key OPENROUTER_API_KEY missing key FAL_KEY present (environment) key TRIPO_API_KEY missing key ELEVENLABS_API_KEY missing +program codex /usr/local/bin/codex (PATH, 0.160.1) +program grok not installed route image.generate gpt-image-2.5-sunburst@openai no key (OPENAI_API_KEY) route image.generate imagegen@codex servable route image.generate img-a@acme no adapter @@ -200,10 +202,16 @@ route mesh.rig v1.0-20240301@tripo no key (TRIPO_API_KEY) … ``` +A `program` line names the Codex or Grok Build a live run would start, where FX found it +(`GRIDA_FX_TOOL_CODEX`, `PATH` or `the ChatGPT app`; `GRIDA_FX_TOOL_GROK`, `PATH` or +`~/.grok/bin`) and, for Codex, the version it names (`no version` when it names none). + A route is `servable`, has `no key` (and names the variable to set), is `not installed (codex)` -or `not installed (grok)` (no Codex or Grok Build found), or has `no adapter` (no provider of FX serves it). A missing key alone doesn't change doctor's exit status, unless the target -uses a route that needs it (below); a `.env` or base URL that a live run would refuse does: doctor -prints `keys ` and exits 1. +or `not installed (grok)` (no Codex or Grok Build found), `too old (codex 0.150.0; FX needs +0.160.1+)` or `no version (codex)` (a Codex a live run would refuse), or has `no adapter` (no +provider of FX serves it). A missing key alone doesn't change doctor's exit status, unless the +target uses a route that needs it (below); a `.env` or base URL that a live run would refuse does: +doctor prints `keys ` and exits 1. **The target's routes come first.** With a target, `grida fx doctor ` lists, right after the Python and Node lines, the routes the workflow's paid steps use, each with whether a live run could diff --git a/docs/guide/12-codex.md b/docs/guide/12-codex.md index 7b713dd1..b400434b 100644 --- a/docs/guide/12-codex.md +++ b/docs/guide/12-codex.md @@ -16,7 +16,7 @@ This page says how to set it up, what it does well, and where it differs from th ``` The ChatGPT app for macOS carries its own copy of Codex, and FX finds it there too when - `codex` is not on your `PATH`. + `codex` is not on your `PATH`. FX needs Codex 0.160.1 or later. 2. Check that FX can see it: @@ -25,12 +25,17 @@ This page says how to set it up, what it does well, and where it differs from th ``` ```text + program codex /usr/local/bin/codex (PATH, 0.160.1) + … route image.edit imagegen@codex servable route image.generate imagegen@codex servable ``` - `not installed (codex)` means FX found no Codex. Put `codex` on your `PATH`, or name the - program with `GRIDA_FX_TOOL_CODEX=/path/to/codex`. + The `program` line says which Codex FX found, where (`PATH`, `GRIDA_FX_TOOL_CODEX` or + `the ChatGPT app`) and its version. `not installed (codex)` means FX found no Codex. Put + `codex` on your `PATH`, or name the program with `GRIDA_FX_TOOL_CODEX=/path/to/codex`. + `too old (codex …)` means the Codex FX found is older than 0.160.1, often an old copy earlier + on your `PATH`: update it, or name a newer one with `GRIDA_FX_TOOL_CODEX`. 3. Pick the route for your project's images in `fx.yaml`: @@ -247,14 +252,16 @@ Codex at all: `Codex was not started: GRIDA_FX_NETWORK is off`. - Your prompts and images go to OpenAI under your ChatGPT account's terms, as when you use Codex yourself. - FX talks to Codex through `codex app-server`, which Codex marks experimental. It was tested with - Codex 0.160.1; a Codex update can change it. A step that no longer understands Codex fails, and - may fail after Codex has drawn (an image FX cannot recognise is lost to it). + Codex 0.160.1, the oldest FX starts; a Codex update can change it. A step that no longer + understands Codex fails, and may fail after Codex has drawn (an image FX cannot recognise is + lost to it). ## When it fails | The step says | What to do | |---|---| | `Codex is not installed: …` | Install Codex and run `codex login`, or set `GRIDA_FX_TOOL_CODEX`. | +| `Codex 0.150.0 (from PATH) is older than 0.160.1: …`, or `Codex (from PATH) did not name its version: …` | Update that Codex (`grida fx doctor` shows which one FX found), or set `GRIDA_FX_TOOL_CODEX` to a newer one. | | `imagegen@codex does not support exact_size` | Leave `size` out, and size the picture after it (above). | | `imagegen@codex takes at most 5 input pictures, …` | Give the step fewer pictures, or use an API route for it. | | `… is image/gif; Codex takes PNG, JPEG or WebP` | Convert the picture first. | diff --git a/llms.txt b/llms.txt index cbcc7830..6b587551 100644 --- a/llms.txt +++ b/llms.txt @@ -41,7 +41,7 @@ says which you have, and `grida fx --help` and `grida fx --help` are alwa npm install -g grida # the grida command, with FX as `grida fx` (Node.js 24 or later) pip install grida # the Python SDK, for nodes and builders in Python (Python 3.11+, brings Pillow) npm install @grida/fx # in a project: the SDK for nodes and builders in TypeScript (Node 24+ to host them) -grida fx doctor # the engine, Python, Node, keys and routes FX found +grida fx doctor # the engine, Python, Node, keys, Codex/Grok Build and routes FX found ``` - `npx grida fx ` works without a global install. `grida-fx` (from `@grida/fx`) and @@ -792,6 +792,7 @@ Python, `run(..., { standIn: fn })` in JavaScript, in your own process. | ` does not support exact_size` (or `alpha`, `mask`, `image_input`) | change the setting or the route; for Codex and Grok Build leave `size` out and resize after | | `no adapter serves …` | FX cannot call that provider; pick a built-in route (`grida fx nodes `) | | `not installed (codex)` / `not installed (grok)` in doctor | install and sign in to Codex or Grok Build, or set `GRIDA_FX_TOOL_CODEX` / `GRIDA_FX_TOOL_GROK` | +| `Codex … is older than 0.160.1` (or `too old (codex …)` in doctor) | update the Codex that doctor's `program` line names, or set `GRIDA_FX_TOOL_CODEX` to a newer one | | `python … too old` / `no grida` in doctor | `uv sync` or `pip install grida` in the project, or set `GRIDA_FX_PYTHON` | | `node … too old` / `no @grida/fx` | Node 24+, `npm install @grida/fx`, or set `GRIDA_FX_NODE` | | `take of no longer produces the picked result …` | the request changed since the pick: `grida fx pick` or `reroll` again | diff --git a/skills/grida-fx/references/onboarding.md b/skills/grida-fx/references/onboarding.md index 8f5b3263..92776d1d 100644 --- a/skills/grida-fx/references/onboarding.md +++ b/skills/grida-fx/references/onboarding.md @@ -131,7 +131,9 @@ Each `key` line says `present` (with its source, `environment` or `.env`) or `missing`, never the value. Each `route` line says whether a live run could serve it; with a workflow, `doctor` exits 1 when a route the workflow uses is not servable. `not installed (codex)` means FX found no Codex: put `codex` on `PATH` -or set `GRIDA_FX_TOOL_CODEX`. +or set `GRIDA_FX_TOOL_CODEX`. `too old (codex …)` means the Codex its `program` +line names is older than 0.160.1: the user updates it or sets +`GRIDA_FX_TOOL_CODEX` to a newer one. ## 5. Pick routes and make the first paid run diff --git a/spec/providers.md b/spec/providers.md index 5be89522..39477707 100644 --- a/spec/providers.md +++ b/spec/providers.md @@ -380,10 +380,11 @@ Each provider's section is normative for its adapters. Status rows override §4. - **Capabilities:** `image.generate` and `image.edit`, on the user's own Codex: the `codex` command, signed in with the user's ChatGPT account. Contract `adapter`: `fx-codex-image-v1`, behaviour `"2"`, the string. The behaviour names the turn's instructions and the conversation's settings below; a change to either is a new behaviour. Behaviour `"1"` named Codex's image skill and took no pictures; it is refused (§5 step 1). - **The model is Codex's.** The route's model, `imagegen`, names Codex's image tool (`image_gen.imagegen`), not a model: Codex chooses the model and the quality, and FX neither picks nor records them, so the route's cache keys outlive a change of either. `independent_of` cannot reason about this route. - **No key, no transport.** The adapter starts `codex app-server` and speaks its JSON-RPC, one JSON message per line on stdin and stdout. Codex owns the sign-in, the model, the network and the usage: FX never reads Codex's credential files, and books nothing in dollars. Codex retries its own requests to OpenAI within a turn; that is Codex's, and FX never resends a Codex call (below). -- **The program:** `GRIDA_FX_TOOL_CODEX` when set and not blank, made absolute against the working directory, and then that program only: none when it names no executable file. Else the first `codex` in an absolute `PATH` entry (empty and relative entries are skipped), else `/Applications/ChatGPT.app/Contents/Resources/codex-cli/bin/codex`. Each must be an executable file. A live invocation looks for it once, and not at all with the network off; `grida-fx doctor` looks for it too, only to report it. +- **The program:** `GRIDA_FX_TOOL_CODEX` when set and not blank, made absolute against the working directory, and then that program only: none when it names no executable file. Else the first `codex` in an absolute `PATH` entry (empty and relative entries are skipped), else `/Applications/ChatGPT.app/Contents/Resources/codex-cli/bin/codex`. Each must be an executable file. A live invocation looks for it once, and not at all with the network off; `grida-fx doctor` looks for it too, only to report it. Where it was found is named `GRIDA_FX_TOOL_CODEX`, `PATH` or `the ChatGPT app`, never by its path (§8). +- **The version.** Codex 0.160.1 or later is needed, the version this behaviour was verified on. FX asks `codex --version` (standard input closed), within 30 s; the version is the first word of what it prints that is `major.minor.patch`, perhaps with a `-` or `+` suffix (`codex-cli 0.160.1`; a leading `v` is passed over), compared by its three numbers. A Codex that exits with a failure, prints none, or prints none within 30 s names no version. `grida-fx doctor` asks the same, only to report it. - **The environment** is FX's, without the eleven variables of §3 and without `CODEX_API_KEY`, `OPENAI_ORG_ID`, `OPENAI_ORGANIZATION`, `OPENAI_PROJECT` and `OPENAI_PROJECT_ID`. Codex's stderr is dropped. - **The user's tools are off, and so are Codex's own.** Every Codex FX starts gets `-c notify=[]`, `-c features.memories=false`, `-c features.hooks=false`, `-c features.apps=false` and `-c features.plugins=false`; then `-c features.=false` for `shell_tool`, `unified_exec`, `multi_agent`, `browser_use`, `computer_use`, `in_app_browser`, `view_image`, `goals`, `sleep_tool`, `tool_suggest` and `skill_search`, and `-c web_search="disabled"`, so a turn is offered its image tool and little else (Codex ignores a feature it does not know, and a turn's context is about half as long); and `-c mcp_servers..enabled=false` for each enabled server `codex mcp list --json` reports under those settings (`` bare when it is letters, digits, `_` and `-`, else a TOML basic string). A server only a plugin defines is not named: Codex refuses a setting for it, and `features.plugins=false` already turns it off. A list Codex will not give within 30 s is a refusal: `Codex's MCP servers could not be listed, so they cannot be turned off`. -- **The runtime.** One `codex app-server` serves an invocation: started on its first Codex call, kept for the others, and shut down with the run (§1). Starting it: the MCP list below, then Codex in a scratch folder of its own, in a process group of its own (its MCP servers and helpers join it), then `initialize`, the `initialized` notification and `account/read`. A Codex that cannot draw on the user's plan (the refusals below) is refused for every call of the invocation, and is not asked again; a Codex that exits is replaced by the next call. Shutting down closes Codex's stdin, gives it 2 s to exit, sends `SIGTERM` to the group and 2 s more, then kills the group; a command that exits on an interrupt kills the group at once. Process groups are Unix's: elsewhere only Codex itself is ended. +- **The runtime.** One `codex app-server` serves an invocation: started on its first Codex call, kept for the others, and shut down with the run (§1). Starting it: the version above, the MCP list below, then Codex in a scratch folder of its own, in a process group of its own (its MCP servers and helpers join it), then `initialize`, the `initialized` notification and `account/read`. A Codex that cannot draw on the user's plan (the refusals below) is refused for every call of the invocation, and is not asked again; a Codex that exits is replaced by the next call. Shutting down closes Codex's stdin, gives it 2 s to exit, sends `SIGTERM` to the group and 2 s more, then kills the group; a command that exits on an interrupt kills the group at once. Process groups are Unix's: elsewhere only Codex itself is ended. - **Pipes.** One task writes Codex's stdin, and one reads its stdout: each response goes to the request waiting for it, each notification to the thread it names (a thread's notifications open with its `thread/start` response, so none is missed), and a request Codex sends (an approval, a question) is answered with error `-32601`: FX grants nothing. - **Pictures.** A call's input pictures are `image` (an edit's), then `references`, in order: at most 5, Codex's own limit (its image tool draws nothing for more). Each is PNG, JPEG or WebP. FX copies each one's store copy into the call's scratch folder as `picture-.` (`n` from 1, two digits), and names them in the turn's text by absolute path, in order, as the image tool's `referenced_image_paths`: Codex reads them through its read-only sandbox, at their own resolution, and sends them to its image endpoint for edits. Nothing is attached to the turn: a picture attached to the conversation is, in Codex's own words, a best-effort reference. A mask is refused: Codex's image tool takes none. - **Shapes.** Codex's image tool takes no size: its images come back about 1.57 megapixels. Without input pictures their shape follows the prompt's wording, between 1:3 and 3:1 (asked for about 4:1, about 1:3 and 16:9, it drew 2172x724, 724x2172 and 1672x941; asked for 1024x1024 it drew 1254x1254). With them, an image takes about the first picture's shape unless the prompt asks for another (a 1000x400 picture, edited, came back 1984x793). @@ -398,6 +399,7 @@ Each provider's section is normative for its adapters. Status rows override §4. - a picture the request does not carry, or whose store copy is empty or unreadable: ` has no bytes to send`; one of another kind: ` is ; Codex takes PNG, JPEG or WebP` (`` is `image` or `references[]`); - **the usage limit**, once a call of the invocation found it reached (an image item's failure `usageLimitExceeded`, or a turn's `codexErrorInfo`): `the Codex usage limit is reached; it resets at unix time , and no Codex call starts before then`, until that time; without a time, `the Codex usage limit is reached, and no Codex call starts again in this run`. Nothing is started for such a call, so a run with many Codex steps stops spending turns on a plan that cannot draw; - no program: `Codex is not installed: install it with npm install -g @openai/codex and sign in with codex login, or set GRIDA_FX_TOOL_CODEX`; with the network off: `Codex was not started: GRIDA_FX_NETWORK is off`. A program that cannot be started: `Codex image generation cannot start codex: `; + - an older Codex (the version, above): `Codex (from ) is older than 0.160.1: update it, or set GRIDA_FX_TOOL_CODEX to a newer one`; one that names no version: `Codex (from ) did not name its version: update it to 0.160.1 or later, or set GRIDA_FX_TOOL_CODEX to a newer one`. `` is where the program was found (above); - the MCP list above; - **an account that does not draw on the user's plan**, from `account/read` when the runtime starts: no account, `Codex is not signed in: run codex login and sign in with ChatGPT`; an API key, `Codex is signed in with an API key, which bills an API account: run codex login and sign in with ChatGPT`; any other, `Codex is signed in with , not ChatGPT: run codex login and sign in with ChatGPT`; - a thread whose `modelProvider` is not `openai`: `Codex uses the model provider , not OpenAI: image generation needs Codex signed in with ChatGPT`. @@ -501,7 +503,7 @@ Each provider's section is normative for its adapters. Status rows override §4. - **Capabilities:** `image.generate` and `image.edit`, on the user's own Grok Build: the `grok` command, signed in with the user's Grok account, whose subscription includes image generation. Contract `adapter`: `fx-grok-image-v1`, behaviour `"1"`, the string. The behaviour names the prompt's instructions, the session's agent and the settings below; a change to any is a new behaviour. - **The model is Grok Build's.** The route's model, `imagine`, names Grok Build's image tools (`image_gen`, `image_edit`), not a model: Grok Build chooses the image model and the agent model, and FX does not pick them; the call record keeps the agent model only (§6). So the route's cache keys outlive a change of either. `independent_of` cannot reason about this route. - **No key, no transport.** The adapter starts `grok agent --no-leader stdio` and speaks the Agent Client Protocol (ACP v1) to it: JSON-RPC 2.0, one JSON message per line on stdin and stdout. `--no-leader` keeps it from joining a Grok Build the user already runs. Grok Build owns the sign-in, the models, the network and the usage: FX never reads Grok Build's credential files, and books nothing in dollars. FX never resends a Grok Build call (below). -- **The program:** `GRIDA_FX_TOOL_GROK` when set and not blank, made absolute against the working directory, and then that program only: none when it names no executable file. Else the first `grok` in an absolute `PATH` entry (empty and relative entries are skipped), else `$HOME/.grok/bin/grok`, where Grok Build's installer puts it. Never `agent`, a name other programs also install. Each must be an executable file. A live invocation looks for it once, and not at all with the network off; `grida-fx doctor` looks for it too, only to report it. +- **The program:** `GRIDA_FX_TOOL_GROK` when set and not blank, made absolute against the working directory, and then that program only: none when it names no executable file. Else the first `grok` in an absolute `PATH` entry (empty and relative entries are skipped), else `$HOME/.grok/bin/grok`, where Grok Build's installer puts it. Never `agent`, a name other programs also install. Each must be an executable file. A live invocation looks for it once, and not at all with the network off; `grida-fx doctor` looks for it too, only to report it. Where it was found is named `GRIDA_FX_TOOL_GROK`, `PATH` or `~/.grok/bin`. - **The environment** is FX's, without the eleven variables of §3 and without `XAI_API_KEY` (an xAI API key bills an API account) and `GROK_CONFIG_PATH`; with `GROK_DISABLE_API_KEY_AUTH=1`, `GROK_DISABLE_AUTOUPDATER=1`, `GROK_MANAGED_MCPS_ENABLED=false`, `GROK___ENABLED=false` for each source `CLAUDE`, `CURSOR` and `CODEX` and each kind `SKILLS`, `RULES`, `AGENTS`, `MCPS`, `HOOKS` and `SESSIONS` (nothing is imported from those programs' settings), and `GROK_CONFIG`, a configuration overlay that turns image generation on and `telemetry`, `video_gen`, `web_fetch`, `lsp_tools`, `write_file`, `backend_tools`, `codebase_indexing`, `ask_user_question`, `session_recap` and `turn_summary` off. Grok Build's stderr is dropped. - **The runtime.** One Grok Build serves an invocation: started on its first Grok call, kept for the others, and shut down with the run (§1). Starting it: Grok Build in a scratch folder of its own, in a process group of its own (whatever it starts joins it), then `initialize` (protocol version 1, and a client that offers no file system and no terminal), whose `_meta.agentVersion` must be 1.0.52 or later, the version this behaviour was verified on; then `authenticate` with the method `cached_token`, the sign-in `grok login` caches. A Grok Build that cannot draw on the user's subscription (the refusals below) is refused for every call of the invocation, and is not asked again; a Grok Build that exits is replaced by the next call. Shutting down is as for Codex (§9.6). - **Sessions.** Each call has a session of its own, in the runtime's scratch folder: its pictures are copied into a folder of the call's own inside it. One working folder keeps what Grok Build records per working folder, such as its prompt history, to one per invocation. A session's agent is FX's (`session/new`'s `_meta.agentProfile`): `{"name": "grida-fx-image", "description": "Draws one image with Grok Build's image tools", "tools": ["image_gen", "image_edit"], "disallowedTools": ["search_tool", "use_tool"]}`, so the model is offered Grok Build's two image tools and nothing else: no shell, no files, no subagents, no web, no MCP tools, whatever the user's own permission settings say. The session asks before every tool call (`_meta.yoloMode: false`), whatever the user's own permission mode, so FX's answers (below) decide each one. `mcpServers` is empty. The session's reasoning effort is set to `low` (`session/set_config_option`); a Grok Build that refuses it draws at its own.