Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,23 @@ models for Claude Code's `/model` picker. Discovery defaults to `system.ai` when
no provider or model location is selected. Use `--provider` or `--model-location`
to select another model source; managed workspace configs control their own sources.

`ug codex` validates discovered models with the installed Codex binary and publishes
them to `~/.ucode/codex-model-catalog.json`, referenced by shared `~/.codex/config.toml`
for Codex App. Managed static lists use the same path during `ug configure`. The
latest refresh supplies the app's catalog; custom catalogs (including Isaac's) and
custom providers are preserved. The app's gateway provider and authentication must
already be configured. Validation covers the local Codex binary.

Codex loads the catalog at app-server startup. When ug reports a catalog change,
finish active tasks, restart the app server on the **connected host**, then reconnect.
Use `codex app-server daemon restart` for a standalone managed daemon; otherwise
restart the process or application that owns the server. Reconnecting or reopening
the desktop app can reuse a remote server with the old list.

ug removes its shared reference on discovery/validation failure, reconfiguration,
revert, or before installing/updating Codex. After an update, run `ug codex` to refresh
discovery or `ug configure` for a managed static list, then restart the app server.

## Configure

```bash
Expand Down Expand Up @@ -166,7 +183,7 @@ with `ug configure` to control installation.

| Tool | Managed files |
|------|---------------|
| Codex | `~/.codex/ucode.config.toml`, legacy `~/.codex/config.toml`, `/etc/codex/managed_config.toml` (Linux and macOS) |
| Codex | `~/.codex/ucode.config.toml`, shared catalog reference in `~/.codex/config.toml`, `~/.ucode/codex-model-catalog.json`, `/etc/codex/managed_config.toml` (Linux and macOS) |
| Claude Code | `~/.claude/ucode-settings.json`, `~/.claude.json`, `/etc/claude-code/managed-settings.json` (Linux), `/Library/Application Support/ClaudeCode/managed-settings.json` (macOS) |
| Gemini CLI | `~/.gemini/ucode.env`, `~/.ucode/.gemini-home/.gemini/settings.json` |
| OpenCode | `~/.ucode/opencode-xdg/opencode/opencode.json`, `~/.ucode/opencode-xdg/opencode/plugin/ucode-auth.js` |
Expand Down
5 changes: 5 additions & 0 deletions src/ucode/agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,9 @@ def _update_installed_tool_binary(tool: str, version: str | None = None) -> bool
command = ["npm", "install", "-g", target]

print_note(f"Upgrading {spec['display']}...")
if tool == "codex":
# Detach potentially incompatible metadata until the next validated refresh.
codex.detach_app_model_catalog()
try:
subprocess.run(command, check=True, timeout=300)
except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
Expand Down Expand Up @@ -224,6 +227,8 @@ def install_tool_binary(

print_section("Bootstrap")
print_warning(f"`{binary}` was not found. Installing {spec['display']}...")
if tool == "codex":
codex.detach_app_model_catalog()
try:
subprocess.run(["npm", "install", "-g", package], check=True, timeout=300)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
Expand Down
133 changes: 121 additions & 12 deletions src/ucode/agents/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
deep_merge_dict,
is_dry_run,
prune_key_paths,
read_json_safe,
read_toml_safe,
write_json_file,
write_toml_file,
Expand Down Expand Up @@ -82,7 +83,7 @@
from ucode.ui import print_warning_err

from .args import LaunchOptions
from .codex_catalog import prepare_codex_catalog
from .codex_catalog import prepare_codex_catalog, validate_codex_catalog

CODEX_CONFIG_DIR = Path.home() / ".codex"
CODEX_PROFILE_NAME = "ucode"
Expand Down Expand Up @@ -369,9 +370,14 @@ def revert_legacy_shared_config() -> bool:
through the workspace gateway. ``ucode revert`` only restored the
per-profile file, leaving those edits in place. Surgically strip them here.

Also remove the shared app catalog reference installed by modern ucode.
Returns True if anything was removed.
"""
return _strip_legacy_ucode_entries(_legacy_config_path())
legacy_changed = _strip_legacy_ucode_entries(_legacy_config_path())
app_catalog_changed = detach_app_model_catalog()
if app_catalog_changed and CODEX_MODEL_CATALOG_PATH.exists():
CODEX_MODEL_CATALOG_PATH.unlink()
return legacy_changed or app_catalog_changed


def configured_paths(state: dict) -> list[str]:
Expand Down Expand Up @@ -448,11 +454,15 @@ def write_tool_config(
catalog_path = str(CODEX_MODEL_CATALOG_PATH) if static_models and not provider else None
# Build and validate before modifying config so failure cannot leave a stale
# catalog enabled or partially rewrite the user's configuration.
catalog = (
prepare_codex_catalog(SPEC["binary"], static_models)
if static_models and not provider
else None
)
try:
catalog = (
prepare_codex_catalog(SPEC["binary"], static_models)
if static_models and not provider
else None
)
except RuntimeError:
_detach_app_catalog_after_failure()
raise

_remove_legacy_ucode_profile()
# Back up only a file that predates ucode's management of the tool. A
Expand Down Expand Up @@ -487,9 +497,11 @@ def compose(base: dict, *, include_catalog: bool = True) -> dict:
return base

if catalog is not None:
write_json_file(CODEX_MODEL_CATALOG_PATH, catalog)
elif CODEX_MODEL_CATALOG_PATH.exists() and not is_dry_run():
CODEX_MODEL_CATALOG_PATH.unlink()
sync_app_model_catalog(catalog)
elif not is_dry_run():
detach_app_model_catalog()
if CODEX_MODEL_CATALOG_PATH.exists():
CODEX_MODEL_CATALOG_PATH.unlink()

doc = read_toml_safe(CODEX_CONFIG_PATH)
compose(doc)
Expand Down Expand Up @@ -783,6 +795,9 @@ def _model_catalog_path(workspace: str, scope: str) -> Path:


def _write_model_catalog(path: Path, catalog: dict) -> None:
if is_dry_run():
write_json_file(path, catalog)
return
temp_path = None
try:
path.parent.mkdir(parents=True, exist_ok=True)
Expand All @@ -803,6 +818,87 @@ def _write_model_catalog(path: Path, catalog: dict) -> None:
pass


def _is_ucode_catalog_reference(value: object) -> bool:
return isinstance(value, str) and Path(value).expanduser() == CODEX_MODEL_CATALOG_PATH


def _read_app_config() -> tomlkit.TOMLDocument:
path = _legacy_config_path()
try:
return tomlkit.parse(path.read_text(encoding="utf-8"))
except FileNotFoundError:
return tomlkit.document()
except (OSError, UnicodeError, ParseError) as exc:
raise RuntimeError(f"Cannot update Codex App settings at {path}: {exc}") from exc


def detach_app_model_catalog() -> bool:
"""Remove only a shared catalog reference owned by ucode."""
if is_dry_run():
return False
doc = _read_app_config()
if not _is_ucode_catalog_reference(doc.get("model_catalog_json")):
return False
doc.pop("model_catalog_json", None)
write_toml_file(_legacy_config_path(), doc)
_print_app_catalog_restart_notice()
return True


def _detach_app_catalog_after_failure() -> None:
"""Keep the original catalog error when shared settings cannot be edited."""
try:
detach_app_model_catalog()
except RuntimeError as exc:
print_warning_err(str(exc))


def _print_app_catalog_restart_notice() -> None:
# A desktop reconnect can reuse a daemon whose model manager still holds
# the startup catalog. Never restart it here: it may be running tasks.
print_warning_err(
"Codex App model catalog changed. Existing app servers keep their startup "
"model list. After active tasks finish, restart the app server on the connected "
"host, then reconnect. For a Codex standalone daemon, run "
"`codex app-server daemon restart`; otherwise restart the process or application "
"that owns the app server. Reconnecting alone does not reload the catalog."
)


def sync_app_model_catalog(catalog: dict) -> None:
"""Publish a validated catalog without overwriting unreadable app settings."""
if is_dry_run():
return
doc = _read_app_config()
catalog_changed = read_json_safe(CODEX_MODEL_CATALOG_PATH) != catalog
_write_model_catalog(CODEX_MODEL_CATALOG_PATH, catalog)

existing = doc.get("model_catalog_json")
if existing is not None and not _is_ucode_catalog_reference(existing):
print_warning_err(
f"Codex App already uses the custom model catalog {existing}; leaving it unchanged."
)
return

catalog_path = str(CODEX_MODEL_CATALOG_PATH)
provider = doc.get("model_provider")
if provider not in (None, CODEX_MODEL_PROVIDER_NAME, LEGACY_CODEX_MODEL_PROVIDER_NAME):
print_warning_err(
f"Codex App uses the custom provider {provider}; leaving its model catalog unmanaged."
)
catalog_path = None

reference_changed = existing != catalog_path
if reference_changed:
if catalog_path is None:
doc.pop("model_catalog_json", None)
else:
doc["model_catalog_json"] = catalog_path
write_toml_file(_legacy_config_path(), doc)
if reference_changed or (catalog_path is not None and catalog_changed):
_print_app_catalog_restart_notice()


def _launch_token(state: dict, workspace: str, *, force_refresh: bool = False) -> str:
"""The token Codex authenticates with: a custom-OAuth client token when configured,
else the CLI profile. The single auth-selection point — the OTLP proxy's token
Expand Down Expand Up @@ -905,6 +1001,9 @@ def _run_codex(
workspace: str | None,
) -> None:
"""Launch Codex — via the loopback proxy when OTLP tracing is on, else exec-replace."""
if tool_args[:1] == ["update"]:
# exec replaces ug, so reattach only on a later validated refresh.
detach_app_model_catalog()
if otel_tracing and workspace:
_launch_codex_with_otel_proxy(state, base_argv, tool_args, workspace)
else:
Expand Down Expand Up @@ -973,7 +1072,10 @@ def launch(
)
_set_provider_header(profile_doc, provider)
_set_parent_schema_header(profile_doc, parent_schema if not provider else None)
if workspace and token and (provider or parent_schema):
updating = tool_args[:1] == ["update"]
if updating and _is_ucode_catalog_reference(profile_doc.get("model_catalog_json")):
profile_doc.pop("model_catalog_json")
if workspace and token and (provider or parent_schema) and not updating:
try:
if provider is not None:
catalog_source = CodexCatalogSource.PROVIDER
Expand All @@ -991,11 +1093,18 @@ def launch(
source=catalog_source,
identifier=catalog_identifier,
)
validate_codex_catalog(binary, catalog)
except CodexMpsModelCatalogUnavailable:
pass
detach_app_model_catalog()
except RuntimeError:
# A failed discovery/validation must not leave a previous workspace's
# catalog active in independently launched app servers.
_detach_app_catalog_after_failure()
raise
else:
catalog_path = _model_catalog_path(workspace, catalog_scope)
_write_model_catalog(catalog_path, catalog)
sync_app_model_catalog(catalog)
profile_doc["model_catalog_json"] = str(catalog_path)
# Codex otherwise boots on its bundled default model (e.g. gpt-5.6-sol),
# which an MPS's allowlist doesn't route, so the first request 403s. Pin
Expand Down
72 changes: 46 additions & 26 deletions src/ucode/agents/codex_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,48 @@ def build_codex_catalog(
return {"models": models}


def _run_catalog_command(
binary: str, args: list[str], home: str
) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[binary, *args],
env={**os.environ, "CODEX_HOME": home},
cwd=home,
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
timeout=_TIMEOUT_SECONDS,
check=True,
)


def _validate_codex_catalog_in_home(binary: str, catalog: dict, home: str) -> None:
candidate = Path(home) / "catalog.json"
candidate.write_text(json.dumps(catalog), encoding="utf-8")
_run_catalog_command(
binary,
[
"-c",
f"model_catalog_json={json.dumps(str(candidate))}",
"debug",
"models",
],
home,
)


def validate_codex_catalog(binary: str, catalog: dict) -> None:
"""Validate a candidate catalog with the selected Codex binary in isolation."""
try:
with tempfile.TemporaryDirectory(prefix="ug-codex-catalog-") as home:
_validate_codex_catalog_in_home(binary, catalog, home)
except (OSError, TypeError, ValueError, subprocess.SubprocessError):
raise RuntimeError(
"Could not load the Codex model catalog with the selected Codex binary. "
"Update Codex and retry `ug codex`."
) from None


def prepare_codex_catalog(binary: str, names: list[str]) -> dict:
"""Extract and validate with the launch binary; never fetch or reuse stale metadata.

Expand All @@ -133,20 +175,7 @@ def prepare_codex_catalog(binary: str, names: list[str]) -> dict:
"""
try:
with tempfile.TemporaryDirectory(prefix="ug-codex-catalog-") as home:
env = {**os.environ, "CODEX_HOME": home}

def run(args: list[str]) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[binary, *args],
env=env,
cwd=home,
capture_output=True,
text=True,
timeout=_TIMEOUT_SECONDS,
check=True,
)

result = run(["debug", "models", "--bundled"])
result = _run_catalog_command(binary, ["debug", "models", "--bundled"], home)
payload = json.loads(result.stdout)
bundled = payload.get("models") if isinstance(payload, dict) else None
if (
Expand All @@ -160,23 +189,14 @@ def run(args: list[str]) -> subprocess.CompletedProcess[str]:
raise ValueError("invalid bundled model catalog")
fallback_warnings: list[str] = []
catalog = build_codex_catalog(bundled, names, warn=fallback_warnings.append)
candidate = Path(home) / "catalog.json"
candidate.write_text(json.dumps(catalog), encoding="utf-8")
run(
[
"-c",
f"model_catalog_json={json.dumps(str(candidate))}",
"debug",
"models",
]
)
except (OSError, ValueError, subprocess.SubprocessError) as exc:
_validate_codex_catalog_in_home(binary, catalog, home)
except (OSError, TypeError, ValueError, subprocess.SubprocessError):
raise RuntimeError(
"Could not build the managed Codex model catalog locally. "
"Upgrade the active Codex installation and verify "
"`codex debug models --bundled` works, then retry configuration. "
"Gateway discovery was not used."
) from exc
) from None
for message in fallback_warnings:
print_warning(message)
return catalog
4 changes: 2 additions & 2 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,10 @@ All tests live directly in `integration/`; shared mechanics live in `utils/`.
| `test_ug_configure_claude_repeat_and_revert`, `test_ug_configure_codex_repeat_and_revert` | Configure twice over user settings; complete a task; revert twice | Settings preserved; no bearer in ug state; generated config removed; status unconfigured |
| `test_ug_configure_claude_cleans_stale_skills_mcp_on_workspace_switch` | Configure the first workspace, register its skills MCP, switch to a second real workspace, and use Claude | Old registration removed from Claude and the new workspace state; old workspace bucket preserved; repeat configure stays clean; real file task completes on the second workspace |
| `test_ug_configure_claude_rejects_invalid_credentials`, `test_ug_configure_codex_rejects_invalid_credentials` | Configure with a rejected bearer against the real workspace | Authentication failure; no successful saved setup |
| `test_ug_configure_managed_claude`, `test_ug_configure_managed_codex` | Configure against a workspace that publishes a managed CodingAgentConfig | No agent selector; each agent's generated config exposes exactly the admin's static model_services; real gateway prompt on launch |
| `test_ug_configure_managed_claude`, `test_ug_configure_managed_codex` | Configure against a workspace that publishes a managed CodingAgentConfig | No agent selector; each agent's generated config exposes exactly the admin's static model_services; real gateway prompt on launch. The Codex case also checks the shared catalog pointer, restart guidance, and a fresh bare app-server's visible model list |
| `test_case_01_*` | Launch managed Claude after configure and from fresh state | Claude receives the admin MPS header, caches exactly the independently fetched provider model IDs, and shows a cached model in a numbered picker row |
| `test_case_03_*`, `test_case_05_*` | Pass a provider or model-location override to managed Claude after configure and from fresh state | ug rejects the override before Claude starts and preserves agent-owned state |
| `test_case_02_*` | Launch managed Codex after configure and from fresh state | Codex's generated catalog and app-server list match the independently fetched admin MPS-scoped model IDs |
| `test_case_02_*` | Launch managed Codex after configure and from fresh state | The scoped and stable catalogs, ug-launched app server, and fresh bare app server match the independently fetched admin MPS model IDs. The configured case uses real `ug revert` to remove ug's shared pointer and stable file while preserving a user setting |
| `test_case_04_*`, `test_case_06_*` | Pass a provider or model-location override to managed Codex after configure and from fresh state | ug rejects the override before Codex starts and preserves agent-owned state |
| `test_ug_configure_managed_codex_catalog_fallback` | Configure from an injected managed response containing a GPT model absent from Codex's bundled catalog | Actionable metadata warning; conservative catalog entry for the unknown model; real Codex prompt on the valid default model |
| `test_managed_fixture_codex_http_headers_in_managed_file` | Interactive PTY configure with injected managed `http_headers` for Codex | The specified header (`x-databricks-workspace`) lands in `model_providers.Databricks.http_headers` in `/etc/codex/managed_config.toml` with the exact admin value |
Expand Down
4 changes: 4 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ def _isolate_ucode_state(tmp_path, monkeypatch):
managed_files_mod, "MANAGED_BACKUP_MANIFEST_PATH", backup_dir / "manifest.json"
)
monkeypatch.setattr(codex_mod, "codex_managed_config_path", lambda: None)
monkeypatch.setattr(
codex_mod, "CODEX_MODEL_CATALOG_PATH", state_dir / "codex-model-catalog.json"
)
monkeypatch.setattr(codex_mod, "CODEX_CONFIG_PATH", tmp_path / ".codex" / "ucode.config.toml")

def reject_privileged_write(path, _desired_text):
pytest.fail(
Expand Down
Loading
Loading