From e96aee0537aaeb8fd6b92177cec5494c0d30765f Mon Sep 17 00:00:00 2001 From: Girish Shankar <12990248+GirishS2093@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:58:47 -0400 Subject: [PATCH 1/2] Point the Claude Code VS Code extension at Unity Gateway The extension starts Claude from VS Code's own settings, not through `ug claude`, so it never sees ~/.claude/ucode-settings.json and signs in to Anthropic. It can launch Claude through a claudeCode.claudeProcessWrapper executable, so: - Add a stdlib-only `ug-claude-vscode` console script that runs the Claude binary the extension passes, adding ug's --settings. - When `ug configure` sets up Claude Code, find every VS Code / VS Code Insiders profile with the extension (plus the VS Code Server's Machine settings) on Windows, macOS and Linux, and set claudeProcessWrapper, disableLoginPrompt and, if unset, initialPermissionMode. settings.json files with comments and wrappers that aren't ug's are left alone (with the lines to add printed), and --dry-run writes nothing. - `ug revert` removes only what ug added (recorded in ~/.ucode/vscode-claude-extension.json). Co-Authored-By: Claude Opus 5.5 --- README.md | 29 +++ pyproject.toml | 2 + src/ucode/cli.py | 15 +- src/ucode/vscode.py | 401 +++++++++++++++++++++++++++++ src/ucode/vscode_wrapper.py | 73 ++++++ tests/README.md | 1 + tests/conftest.py | 4 + tests/test_cli.py | 31 +++ tests/test_vscode.py | 486 +++++++++++++++++++++++++++++++++++ tests/test_vscode_wrapper.py | 133 ++++++++++ 10 files changed, 1174 insertions(+), 1 deletion(-) create mode 100644 src/ucode/vscode.py create mode 100644 src/ucode/vscode_wrapper.py create mode 100644 tests/test_vscode.py create mode 100644 tests/test_vscode_wrapper.py diff --git a/README.md b/README.md index 4ba257501..a13cf464c 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,34 @@ Cursor models still run through your Cursor account. `UG_WORKSPACE` can provide the default workspace. An explicit `--workspace` or `--profile` takes precedence. +## Claude Code in VS Code + +The Claude Code VS Code extension doesn't start Claude through `ug claude`, so on its +own it signs in to Anthropic. Whenever `ug configure` sets up Claude Code, it also looks +for the extension and points every VS Code profile that has it at Unity Gateway: + +- `ug` installs a small launcher, `ug-claude-vscode`, next to `ug`. The extension starts + Claude through it, and it adds `ug`'s gateway settings (`~/.claude/ucode-settings.json`). +- In the `settings.json` of each profile with the extension, `ug` sets: + + ```json + "claudeCode.claudeProcessWrapper": "", + "claudeCode.disableLoginPrompt": true, + "claudeCode.initialPermissionMode": "default" + ``` + + `initialPermissionMode` is added only when it isn't already set. +- On Windows, covers VS Code and VS Code Insiders (the default profile and named profiles). + +Reload VS Code afterwards. `ug` leaves a `settings.json` alone when it contains comments +(it prints the lines to add instead) or when the extension already uses a different +wrapper. `ug revert` removes only the settings `ug` added. + +In the extension you get the gateway, automatic token refresh, the admin's model list and +`ug`'s MCP servers. Smart routing needs `ug claude`. Run `ug configure` again after your +admin changes models. To see how the extension launches Claude, set +`UG_CLAUDE_VSCODE_LOG=` through the `claudeCode.environmentVariables` setting. + ## MCP Servers Register Databricks MCP servers for configured MCP-capable agents. Cursor Agent @@ -235,6 +263,7 @@ unchanged. Native daemon/background propagation of the plugin remains unverified |------|---------------| | 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) | +| Claude Code VS Code extension | the three `claudeCode.*` keys above in each profile's VS Code `settings.json`, and `~/.ucode/vscode-claude-extension.json` (what `ug` changed, for `ug revert`) | | 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` | | GitHub Copilot CLI | `~/.copilot/ucode.env`, `~/.copilot/ucode-mcp-config.json` | diff --git a/pyproject.toml b/pyproject.toml index 39fbd9cf9..5cb8a33a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,8 @@ dependencies = [ [project.scripts] ug = "ucode.cli:main" ucode = "ucode.cli:main" +# claudeCode.claudeProcessWrapper for the Claude Code VS Code extension; see ucode.vscode. +ug-claude-vscode = "ucode.vscode_wrapper:main" [tool.hatch.build.targets.wheel] # Keep the Python module and existing ~/.ucode configuration paths compatible. diff --git a/src/ucode/cli.py b/src/ucode/cli.py index f8e58f09c..95b1e6535 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -19,7 +19,7 @@ from typer import _click from typer.core import HAS_RICH, TyperCommand, TyperGroup, TyperOption -from ucode import custom_oauth +from ucode import custom_oauth, vscode from ucode.agents import ( TOOL_SPECS, LaunchOptions, @@ -791,6 +791,12 @@ def _use_databricks() -> dict: return state +def _configure_vscode_claude_extension(configured_tools: list[str]) -> None: + """Point VS Code's Claude Code extension at Unity Gateway once Claude Code is configured.""" + if "claude" in configured_tools: + vscode.configure_claude_extension() + + def configure_workspace_command( tool: str | None = None, selected_tools: list[str] | None = None, @@ -859,6 +865,7 @@ def _configure_workspace_command( if not managed_provider_service(managed, tool): parent_schema = managed_unity_catalog_location(managed, tool) state = configure_single_tool(tool, state, parent_schema=parent_schema) + _configure_vscode_claude_extension([tool]) install_databricks_ai_tools_for_agents( [tool], state, force_refresh=tool not in ("claude", "codex") ) @@ -937,6 +944,7 @@ def _configure_workspace_command( if not is_dry_run(): registered_mcps = _configure_managed_mcp_servers(managed) _configure_managed_skills(managed) + _configure_vscode_claude_extension(configured_tools) _summarize_managed_config(managed, configured_tools, registered_mcps) return 0 @@ -999,6 +1007,9 @@ def _configure_workspace_command( configured_set = set( last_configured if last_configured is not None else state.get("available_tools") or [] ) + _configure_vscode_claude_extension( + [tool_name for tool_name in picked if tool_name in configured_set] + ) summary_lines = [f"[bold]Workspace:[/bold] [cyan]{state['workspace']}[/cyan]"] for tool_name in picked: spec = TOOL_SPECS[tool_name] @@ -1281,6 +1292,7 @@ def revert() -> int: # Older Codex (< 0.134.0) had ucode edit the shared ~/.codex/config.toml in # place; restoring the per-profile file above does not undo that. legacy_codex_stripped = revert_legacy_shared_config() + vscode_result = vscode.revert_claude_extension() clear_state() print_heading("Revert") @@ -1292,6 +1304,7 @@ def revert() -> int: print_kv("Claude Code OS-managed settings", claude_managed_result) print_kv("Codex OS-managed settings", codex_managed_result) print_kv("Pi settings", "restored" if pi_settings_restored else "unchanged") + print_kv("VS Code Claude Code extension", vscode_result) for client, spec in MCP_CLIENTS.items(): print_kv( f"{spec['display']} MCP config", diff --git a/src/ucode/vscode.py b/src/ucode/vscode.py new file mode 100644 index 000000000..b9aa07fd2 --- /dev/null +++ b/src/ucode/vscode.py @@ -0,0 +1,401 @@ +"""Point the Windows Claude Code VS Code extension at Unity Gateway. + +The extension launches Claude from VS Code's own settings rather than through ``ug claude``, +so it never sees ``~/.claude/ucode-settings.json`` and falls back to Anthropic's sign-in. It +can, however, launch Claude through a ``claudeCode.claudeProcessWrapper`` executable. ug +installs ``ug-claude-vscode`` for that (:mod:`ucode.vscode_wrapper`); ``ug configure`` points +every VS Code that has the extension at it, and ``ug revert`` removes only what ug added. + +Only strict-JSON ``settings.json`` files are edited: rewriting one that has comments or +trailing commas would drop them, so those get the settings printed for manual entry. +""" + +from __future__ import annotations + +import json +import os +import shutil +import sys +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path, PureWindowsPath + +from ucode import config_io +from ucode.ui import print_note, print_success, print_warning + +CLAUDE_EXTENSION_ID = "anthropic.claude-code" +WRAPPER_COMMAND = "ug-claude-vscode" +WRAPPER_SETTING = "claudeCode.claudeProcessWrapper" +DISABLE_LOGIN_SETTING = "claudeCode.disableLoginPrompt" +PERMISSION_MODE_SETTING = "claudeCode.initialPermissionMode" +# With a wrapper the extension skips its usual permission-mode defaults and starts in Manual; +# setting Manual (`default`) explicitly keeps that predictable. A user's own choice wins. +DEFAULT_PERMISSION_MODE = "default" +_RECORD_NAME = "vscode-claude-extension.json" + + +@dataclass(frozen=True) +class VSCodeInstall: + """One Windows VS Code flavor on this machine. + + ``settings_path`` is the default profile's settings.json. ``user_dir`` is the ``User`` + folder that holds the other profiles (``profiles//``). + """ + + name: str + extensions_dir: Path + settings_path: Path + user_dir: Path + + +@dataclass(frozen=True) +class SettingsTarget: + """A settings.json whose VS Code profile has the Claude Code extension.""" + + name: str + settings_path: Path + + +def vscode_installs() -> list[VSCodeInstall]: + """Windows VS Code installations whose user settings ug can update.""" + if sys.platform != "win32": + return [] + home = Path.home() + appdata = Path(os.environ.get("APPDATA") or home / "AppData" / "Roaming") + + def user_dir(product: str) -> Path: + return appdata / product / "User" + + return [ + VSCodeInstall( + "VS Code", + home / ".vscode" / "extensions", + user_dir("Code") / "settings.json", + user_dir("Code"), + ), + VSCodeInstall( + "VS Code Insiders", + home / ".vscode-insiders" / "extensions", + user_dir("Code - Insiders") / "settings.json", + user_dir("Code - Insiders"), + ), + ] + + +def has_claude_extension(install: VSCodeInstall) -> bool: + """Whether a Claude Code extension folder exists in ``install`` (any installed version).""" + prefix = f"{CLAUDE_EXTENSION_ID}-" + try: + return any( + entry.is_dir() and entry.name.lower().startswith(prefix) + for entry in install.extensions_dir.iterdir() + ) + except OSError: + return False + + +def _lists_claude_extension(extensions_json: Path) -> bool | None: + """Whether a profile's ``extensions.json`` lists the extension; None when it can't be read.""" + try: + entries = json.loads(extensions_json.read_text(encoding="utf-8-sig")) + except (OSError, UnicodeError, json.JSONDecodeError): + return None + if not isinstance(entries, list): + return None + return any( + isinstance(entry, dict) + and isinstance(entry.get("identifier"), dict) + and str(entry["identifier"].get("id", "")).lower() == CLAUDE_EXTENSION_ID + for entry in entries + ) + + +def _profiles(user_dir: Path) -> list[dict]: + """VS Code's named profiles (``userDataProfiles`` in ``globalStorage/storage.json``).""" + try: + storage = json.loads( + (user_dir / "globalStorage" / "storage.json").read_text(encoding="utf-8-sig") + ) + except (OSError, UnicodeError, json.JSONDecodeError): + return [] + profiles = storage.get("userDataProfiles") if isinstance(storage, dict) else None + return [p for p in profiles if isinstance(p, dict)] if isinstance(profiles, list) else [] + + +def claude_settings_targets(install: VSCodeInstall) -> list[SettingsTarget]: + """The settings.json of every profile in ``install`` that has the Claude Code extension. + + The default profile's extensions are listed in ``/extensions.json``; each + other profile has its own ``extensions.json`` and ``settings.json`` under + ``User/profiles//`` unless its ``useDefaultFlags`` shares the default profile's. + """ + listed = _lists_claude_extension(install.extensions_dir / "extensions.json") + default_has = has_claude_extension(install) if listed is None else listed + targets: list[SettingsTarget] = [] + if default_has: + targets.append(SettingsTarget(install.name, install.settings_path)) + for profile in _profiles(install.user_dir): + location = profile.get("location") + if not isinstance(location, str) or not location: + continue + flags = profile.get("useDefaultFlags") + flags = flags if isinstance(flags, dict) else {} + profile_dir = _safe_profile_dir(install.user_dir, location) + if profile_dir is None: + print_warning(f"{install.name}: ignored unsafe VS Code profile location {location!r}.") + continue + has = ( + default_has + if flags.get("extensions") + else _lists_claude_extension(profile_dir / "extensions.json") is True + ) + if not has: + continue + settings = install.settings_path if flags.get("settings") else profile_dir / "settings.json" + targets.append( + SettingsTarget(f"{install.name} (profile {profile.get('name') or location})", settings) + ) + unique: dict[Path, SettingsTarget] = {} + for target in targets: + unique.setdefault(target.settings_path, target) + return list(unique.values()) + + +def _safe_profile_dir(user_dir: Path, location: str) -> Path | None: + """Resolve a named profile below ``User/profiles`` without accepting traversal paths.""" + native_location = Path(location) + windows_location = PureWindowsPath(location) + if ( + native_location.is_absolute() + or windows_location.is_absolute() + or ".." in native_location.parts + or ".." in windows_location.parts + ): + return None + profiles_dir = (user_dir / "profiles").resolve() + profile_dir = (profiles_dir / native_location).resolve() + try: + profile_dir.relative_to(profiles_dir) + except ValueError: + return None + return profile_dir + + +def wrapper_executable() -> str | None: + """The installed ``ug-claude-vscode`` executable, if any.""" + # Installed next to `ug` by the same package even when that directory isn't on PATH. + if sys.argv and sys.argv[0]: + suffix = ".exe" if os.name == "nt" else "" + sibling = Path(sys.argv[0]).absolute().with_name(WRAPPER_COMMAND + suffix) + if sibling.is_file(): + return str(sibling) + return shutil.which(WRAPPER_COMMAND) + + +def _is_ug_wrapper(value: object) -> bool: + """A wrapper setting ug wrote, possibly from another install location.""" + if not isinstance(value, str) or not value.strip(): + return False + return Path(value.strip()).stem.lower() == WRAPPER_COMMAND + + +def _record_path() -> Path: + return config_io.APP_DIR / _RECORD_NAME + + +def _read_record() -> dict[str, dict]: + try: + data = json.loads(_record_path().read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError): + return {} + return data if isinstance(data, dict) else {} + + +def _write_record(record: dict[str, dict]) -> None: + path = _record_path() + if not record: + path.unlink(missing_ok=True) + return + config_io.atomic_write_json(path, record) + + +def _read_settings(path: Path) -> dict | None: + """``settings.json`` as a dict ({} when absent), or None when it isn't strict JSON.""" + try: + text = path.read_text(encoding="utf-8-sig") + except FileNotFoundError: + return {} + except (OSError, UnicodeError): + return None + if not text.strip(): + return {} + try: + data = json.loads(text) + except json.JSONDecodeError: + return None + return data if isinstance(data, dict) else None + + +def _desired_settings(doc: dict, wrapper: str) -> dict[str, object]: + desired: dict[str, object] = {WRAPPER_SETTING: wrapper, DISABLE_LOGIN_SETTING: True} + if PERMISSION_MODE_SETTING not in doc: + desired[PERMISSION_MODE_SETTING] = DEFAULT_PERMISSION_MODE + return desired + + +def _manual_lines(wrapper: str) -> str: + return "\n".join( + f" {json.dumps(key)}: {json.dumps(value)}," + for key, value in ( + (WRAPPER_SETTING, wrapper), + (DISABLE_LOGIN_SETTING, True), + (PERMISSION_MODE_SETTING, DEFAULT_PERMISSION_MODE), + ) + ) + + +def _configure_install(install: SettingsTarget, wrapper: str, record: dict[str, dict]) -> None: + path = install.settings_path + doc = _read_settings(path) + if doc is None: + print_warning( + f"{install.name}: {path} has comments or isn't plain JSON, so ug left it untouched. " + "To use Unity Gateway in the Claude Code extension, add these settings yourself:\n" + + _manual_lines(wrapper) + ) + return + current = doc.get(WRAPPER_SETTING) + if current and not _is_ug_wrapper(current): + print_warning( + f"{install.name}: the Claude Code extension already launches through {current}; " + f"ug left it alone. To use Unity Gateway instead, set {WRAPPER_SETTING} to {wrapper} " + f"in {path}." + ) + return + changes = { + key: value + for key, value in _desired_settings(doc, wrapper).items() + if doc.get(key) != value + } + if not changes: + print_note(f"{install.name}: the Claude Code extension already uses Unity Gateway.") + return + entry = record.setdefault(str(path), {"set": {}, "previous": {}}) + for key, value in changes.items(): + if key in doc and key not in entry["previous"] and key not in entry["set"]: + entry["previous"][key] = doc[key] + doc[key] = value + entry["set"][key] = value + if not config_io.is_dry_run(): + # Keep the intended rollback data durable before changing the user's settings. + _write_record(record) + config_io.write_text_file(path, json.dumps(doc, indent=4, ensure_ascii=False) + "\n") + if not config_io.is_dry_run(): + print_success( + f"{install.name}: Claude Code extension set to use Unity Gateway ({path}). " + "Reload VS Code to apply." + ) + + +def configure_claude_extension( + installs: Callable[[], list[VSCodeInstall]] | None = None, +) -> None: + """Point every VS Code profile with the Claude Code extension at ug's wrapper. + + Silent when no VS Code on this machine has the extension. + """ + targets = [ + target + for install in (installs or vscode_installs)() + for target in claude_settings_targets(install) + ] + if not targets: + return + wrapper = wrapper_executable() + if wrapper is None: + print_warning( + f"The Claude Code VS Code extension is installed, but `{WRAPPER_COMMAND}` was not " + "found; reinstall Unity Gateway to let the extension use it." + ) + return + record = _read_record() + for install in targets: + try: + _configure_install(install, wrapper, record) + except (OSError, RuntimeError) as exc: + print_warning( + f"{install.name}: couldn't update {install.settings_path}; retained its " + f"revert record ({exc}). Run `ug revert` after fixing the file permissions." + ) + if not config_io.is_dry_run(): + try: + _write_record(record) + except (OSError, RuntimeError) as exc: + print_warning( + "VS Code Claude Code extension: couldn't persist its revert records; " + f"run `ug configure` again after fixing the file permissions ({exc})." + ) + + +def revert_claude_extension() -> str: + """Undo the settings ug added, leaving any a user has since changed. Returns a summary.""" + record = _read_record() + reverted = False + pending: dict[str, dict] = {} + for settings_path, entry in list(record.items()): + path = Path(settings_path) + doc = _read_settings(path) + if doc is None: + pending[settings_path] = entry + print_warning( + f"VS Code Claude Code extension: couldn't read {path}; retained its revert " + "record. Fix the JSON/comments or permissions, then run `ug revert` again." + ) + continue + set_values = entry.get("set") + previous = entry.get("previous") + if not isinstance(set_values, dict) or not isinstance(previous, dict): + pending[settings_path] = entry + print_warning( + f"VS Code Claude Code extension: revert record for {path} is invalid; " + "retained it for manual cleanup." + ) + continue + changed = False + for key, value in set_values.items(): + if doc.get(key) != value: + continue # changed since ug set it: the user's value stays + if key in previous: + doc[key] = previous[key] + else: + doc.pop(key, None) + changed = True + if changed: + try: + config_io.write_text_file( + path, json.dumps(doc, indent=4, ensure_ascii=False) + "\n" + ) + except (OSError, RuntimeError) as exc: + pending[settings_path] = entry + print_warning( + f"VS Code Claude Code extension: couldn't restore {path}; retained its " + f"revert record ({exc})." + ) + continue + reverted = True + if config_io.is_dry_run(): + pending[settings_path] = entry + if config_io.is_dry_run(): + pending = record + else: + try: + _write_record(pending) + except (OSError, RuntimeError) as exc: + pending = record + print_warning( + "VS Code Claude Code extension: couldn't update its revert records; " + f"some entries remain pending ({exc})." + ) + if pending: + return "partially restored" if reverted else "unchanged (manual cleanup needed)" + return "restored" if reverted else "unchanged" diff --git a/src/ucode/vscode_wrapper.py b/src/ucode/vscode_wrapper.py new file mode 100644 index 000000000..8660661f2 --- /dev/null +++ b/src/ucode/vscode_wrapper.py @@ -0,0 +1,73 @@ +"""``ug-claude-vscode``: launch Claude Code with ug's gateway settings from an IDE extension. + +The Claude Code VS Code extension reads only the standard settings files, never the +``--settings ~/.claude/ucode-settings.json`` that ``ug claude`` passes. It can instead launch +Claude through a ``claudeCode.claudeProcessWrapper`` executable, passing its bundled Claude +binary first (when it has one) and then Claude's own arguments. This is that executable: +it adds ug's ``--settings`` and hands over, with stdin/stdout inherited so the extension +talks to Claude directly. + +Keep startup small and write nothing to stdout: it runs on every Claude launch from the +extension, and stdout carries the extension's protocol. +""" + +from __future__ import annotations + +import json +import os +import shutil +import sys +from pathlib import Path + +from ucode.os_compatibility import subprocess_cross_os + +# Must equal ``ucode.agents.claude.CLAUDE_SETTINGS_PATH``; not imported, to keep startup light. +UG_CLAUDE_SETTINGS = Path.home() / ".claude" / "ucode-settings.json" +# Troubleshooting: append each launch's argv (JSON) to this file. +LOG_ENV_VAR = "UG_CLAUDE_VSCODE_LOG" +_JS_SUFFIXES = {".js", ".mjs", ".cjs"} + + +def claude_command(args: list[str]) -> tuple[list[str], list[str]]: + """Split off the Claude program the extension passed, or fall back to ``claude`` on PATH.""" + if args and Path(args[0]).is_file(): + program = Path(args[0]) + if program.suffix.lower() in _JS_SUFFIXES: + node = shutil.which("node") + if node is None: + sys.exit( + "ug-claude-vscode: the extension passed a JavaScript Claude build, " + "but `node` is not on PATH." + ) + return [node, str(program)], args[1:] + return [str(program)], args[1:] + claude = shutil.which("claude") + if claude is None: + sys.exit("ug-claude-vscode: no Claude binary was passed and `claude` is not on PATH.") + return [claude], args + + +def main() -> int: + if not UG_CLAUDE_SETTINGS.is_file(): + sys.exit(f"ug-claude-vscode: {UG_CLAUDE_SETTINGS} not found; run `ug configure` first.") + program, args = claude_command(sys.argv[1:]) + conflicting_settings = next( + (arg for arg in args if arg == "--settings" or arg.startswith("--settings=")), None + ) + if conflicting_settings is not None: + print( + "ug-claude-vscode: refusing to launch because the caller passed " + f"{conflicting_settings!r}; ug supplies the only supported --settings value. " + "Remove that argument and retry.", + file=sys.stderr, + ) + return 2 + if log := os.environ.get(LOG_ENV_VAR): + with open(log, "a", encoding="utf-8") as f: + f.write(json.dumps(sys.argv) + "\n") + try: + return subprocess_cross_os.run( + [*program, "--settings", str(UG_CLAUDE_SETTINGS), *args], check=False + ).returncode + except KeyboardInterrupt: + return 130 diff --git a/tests/README.md b/tests/README.md index 2f2f3ccad..c7225f204 100644 --- a/tests/README.md +++ b/tests/README.md @@ -257,6 +257,7 @@ These are unit/component checks; they do not establish live sudo password-prompt | Claude/Codex interactive smart routing | First-prompt routing is covered by the `managed_fixture` banner journeys. The live subagent-only journeys spawn native children and invoke the installed Smart Router skill to verify routing on -> off -> on within one Claude/Codex session. Plugin-refresh survival, native daemon/background dispatch, interactive explicit-model bypass, and dedicated routing CI shards remain deferred. | | Full allow/deny tool-permission matrix | Not covered; onboarding/trust uses actual TUI choices | | Desktop Codex app, Isaac itself, auto-upgrades | Not covered by command forwarding or pinned-version tests | +| Claude Code VS Code extension | Windows-only unit/component tests (`test_vscode.py`, `test_vscode_wrapper.py`, CLI configure/revert wiring). No journey drives a live VS Code window; checked by hand against a real extension | | Native macOS/Windows live TUI, managed settings, resize/signals | Windows fresh-install, CLI, local helpers, and the Claude headless gateway journey are advisory; Codex Windows CI is blocked on npm proxy access. Live PTY/TUI, managed settings, and signal behavior still need separate platform implementation and coverage | | Other agents | Live scope is Claude Code and Codex, plus a non-blocking OpenCode headless case | | OpenCode `--model` / `-m` | Unit/component tests cover raw CLI forwarding, configured model selection, launch arguments, rejection of unknown models before starting the native process, and the existing failure when no default model is available; no dedicated live integration journey | diff --git a/tests/conftest.py b/tests/conftest.py index 4ae9e4362..2fb1af4a9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -35,6 +35,7 @@ def _isolate_ucode_state(tmp_path, monkeypatch): import ucode.managed_files as managed_files_mod import ucode.os_compatibility.subprocess_cross_os as subprocess_cross_os_mod import ucode.state as state_mod + import ucode.vscode as vscode_mod from ucode.agents import codex as codex_mod state_dir = tmp_path / ".ucode" @@ -76,6 +77,9 @@ def reject_privileged_write(path, _desired_text): ) # A developer's ambient managed-config stub would otherwise short-circuit every fetch in the suite. monkeypatch.delenv("UCODE_MANAGED_CONFIG_STUB", raising=False) + # `ug configure` points an installed Claude Code VS Code extension at ug; never let a + # test find, or edit, the developer's real VS Code settings. + monkeypatch.setattr(vscode_mod, "vscode_installs", lambda: []) # The model-services listing is memoized for the life of the process, so without this a cached # result would leak into the next test and make a stubbed listing look like it was never called. databricks_mod.clear_model_services_cache() diff --git a/tests/test_cli.py b/tests/test_cli.py index 92de68d5d..9f69fb8fc 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2773,6 +2773,37 @@ def test_reverts_mcp_configs_before_clearing_state(self): assert cleared == [True] assert "Claude Code MCP config: restored" in result.output + def test_reverts_the_vscode_extension_settings_before_clearing_state(self): + order: list[str] = [] + + with ( + patch("ucode.cli.load_state", return_value=MINIMAL_STATE), + patch("ucode.cli.restore_file", return_value=False), + patch("ucode.cli.revert_mcp_configs", return_value={}), + patch( + "ucode.cli.vscode.revert_claude_extension", + side_effect=lambda: order.append("vscode") or "restored", + ), + patch("ucode.cli.clear_state", side_effect=lambda: order.append("clear")), + ): + result = runner.invoke(app, ["revert"]) + + assert result.exit_code == 0, result.output + assert order == ["vscode", "clear"] + assert "VS Code Claude Code extension: restored" in _strip_ansi(result.output) + + +class TestVSCodeExtensionHook: + @pytest.mark.parametrize( + ("configured", "expected_calls"), + [(["claude"], 1), (["codex", "claude"], 1), (["codex"], 0), ([], 0)], + ) + def test_runs_only_when_claude_code_was_configured(self, configured, expected_calls): + with patch("ucode.cli.vscode.configure_claude_extension") as configure: + cli_mod._configure_vscode_claude_extension(configured) + + assert configure.call_count == expected_calls + class TestDoctorCommand: def test_invokes_doctor(self): diff --git a/tests/test_vscode.py b/tests/test_vscode.py new file mode 100644 index 000000000..70027e781 --- /dev/null +++ b/tests/test_vscode.py @@ -0,0 +1,486 @@ +"""Tests for ucode.vscode: pointing the Claude Code VS Code extension at Unity Gateway.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from ucode import config_io, vscode + +# Captured at import: the autouse fixture replaces vscode.vscode_installs so no other test can +# reach the developer's real VS Code settings. +from ucode.vscode import vscode_installs as real_vscode_installs + +WRAPPER = "/tools/bin/ug-claude-vscode" +WRAPPER_KEY = vscode.WRAPPER_SETTING +LOGIN_KEY = vscode.DISABLE_LOGIN_SETTING +MODE_KEY = vscode.PERMISSION_MODE_SETTING + + +def _extensions_json(path: Path, *ids: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + entries = [{"identifier": {"id": ext_id}, "version": "1.0.0"} for ext_id in ids] + path.write_text(json.dumps(entries), encoding="utf-8") + + +def _install( + tmp_path, + *, + extension="anthropic.claude-code-2.1.282-win32-x64", + name="VS Code", + listed: tuple[str, ...] | None = None, + profiles: list[dict] | None = None, +): + """A VS Code install under tmp_path. ``listed`` writes the default profile's extensions.json.""" + root = tmp_path / name + extensions_dir = root / "extensions" + extensions_dir.mkdir(parents=True) + if extension: + (extensions_dir / extension).mkdir() + if listed is not None: + _extensions_json(extensions_dir / "extensions.json", *listed) + user_dir = root / "User" + if profiles is not None: + storage = user_dir / "globalStorage" / "storage.json" + storage.parent.mkdir(parents=True) + storage.write_text(json.dumps({"userDataProfiles": profiles}), encoding="utf-8") + return vscode.VSCodeInstall(name, extensions_dir, user_dir / "settings.json", user_dir) + + +def _write_settings(install, doc_or_text): + install.settings_path.parent.mkdir(parents=True, exist_ok=True) + text = doc_or_text if isinstance(doc_or_text, str) else json.dumps(doc_or_text, indent=4) + install.settings_path.write_text(text, encoding="utf-8") + + +def _settings(install) -> dict: + return json.loads(install.settings_path.read_text(encoding="utf-8")) + + +def _record() -> dict: + return json.loads( + (config_io.APP_DIR / "vscode-claude-extension.json").read_text(encoding="utf-8") + ) + + +def _configure(install): + vscode.configure_claude_extension(installs=lambda: [install]) + + +@pytest.fixture +def wrapper(monkeypatch): + monkeypatch.setattr(vscode, "wrapper_executable", lambda: WRAPPER) + return WRAPPER + + +@pytest.fixture +def messages(monkeypatch): + captured: dict[str, list[str]] = {"warning": [], "note": [], "success": []} + monkeypatch.setattr(vscode, "print_warning", captured["warning"].append) + monkeypatch.setattr(vscode, "print_note", captured["note"].append) + monkeypatch.setattr(vscode, "print_success", captured["success"].append) + return captured + + +class TestInstallLocations: + @pytest.fixture + def home(self, monkeypatch, tmp_path): + monkeypatch.setattr(vscode.Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.delenv("APPDATA", raising=False) + return tmp_path + + def _by_name(self): + return {install.name: install for install in real_vscode_installs()} + + def test_windows_uses_appdata(self, monkeypatch, home): + monkeypatch.setattr(vscode.sys, "platform", "win32") + monkeypatch.setenv("APPDATA", str(home / "Roaming")) + installs = self._by_name() + assert ( + installs["VS Code"].settings_path + == home / "Roaming" / "Code" / "User" / "settings.json" + ) + assert installs["VS Code Insiders"].settings_path == ( + home / "Roaming" / "Code - Insiders" / "User" / "settings.json" + ) + assert set(installs) == {"VS Code", "VS Code Insiders"} + assert installs["VS Code"].extensions_dir == home / ".vscode" / "extensions" + + @pytest.mark.parametrize("platform", ["darwin", "linux"]) + def test_non_windows_does_not_discover_vscode(self, monkeypatch, home, platform): + monkeypatch.setattr(vscode.sys, "platform", platform) + + assert real_vscode_installs() == [] + + +class TestDetection: + @pytest.mark.parametrize( + ("folder", "expected"), + [ + ("anthropic.claude-code-2.1.282", True), + ("anthropic.claude-code-2.1.282-win32-x64", True), + ("Anthropic.Claude-Code-2.1.282", True), + ("anthropic.claude-codex-1.0.0", False), + ("ms-python.python-2026.1.0", False), + (None, False), + ], + ) + def test_extension_folder(self, tmp_path, folder, expected): + assert vscode.has_claude_extension(_install(tmp_path, extension=folder)) is expected + + def test_missing_extensions_dir(self, tmp_path): + install = vscode.VSCodeInstall( + "VS Code", tmp_path / "nope", tmp_path / "settings.json", tmp_path / "User" + ) + assert vscode.has_claude_extension(install) is False + + +class TestProfiles: + CLAUDE = vscode.CLAUDE_EXTENSION_ID + + def _names(self, install): + return [(t.name, t.settings_path) for t in vscode.claude_settings_targets(install)] + + def test_extensions_json_is_authoritative_over_leftover_folders(self, tmp_path): + # VS Code keeps old version folders until cleanup; the registry says what's installed. + install = _install(tmp_path, listed=("ms-python.python",)) + assert self._names(install) == [] + + def test_folder_check_when_there_is_no_extensions_json(self, tmp_path): + install = _install(tmp_path) + assert self._names(install) == [("VS Code", install.settings_path)] + + def test_each_profile_with_the_extension_gets_its_own_settings(self, tmp_path): + profiles = [ + {"name": "Dev", "location": "-16c12516"}, + {"name": "QA", "location": "-712ed176"}, + { + "name": "Agents", + "location": "builtin/agents", + "useDefaultFlags": {"settings": True, "extensions": True}, + }, + ] + install = _install(tmp_path, listed=(self.CLAUDE,), profiles=profiles) + user = install.user_dir + _extensions_json(user / "profiles" / "-16c12516" / "extensions.json", self.CLAUDE) + _extensions_json(user / "profiles" / "-712ed176" / "extensions.json", "ms-python.python") + + assert self._names(install) == [ + ("VS Code", install.settings_path), + ("VS Code (profile Dev)", user / "profiles" / "-16c12516" / "settings.json"), + # "Agents" shares the default profile's settings, so it isn't listed twice. + ] + + def test_profile_sharing_default_extensions_uses_its_own_settings(self, tmp_path): + profiles = [{"name": "Work", "location": "abc", "useDefaultFlags": {"extensions": True}}] + install = _install(tmp_path, listed=(self.CLAUDE,), profiles=profiles) + + assert self._names(install)[-1] == ( + "VS Code (profile Work)", + install.user_dir / "profiles" / "abc" / "settings.json", + ) + + def test_profile_only_install(self, tmp_path): + # The default profile doesn't have the extension; one named profile does. + profiles = [{"name": "Dev", "location": "dev"}] + install = _install(tmp_path, extension=None, listed=(), profiles=profiles) + _extensions_json(install.user_dir / "profiles" / "dev" / "extensions.json", self.CLAUDE) + + assert self._names(install) == [ + ("VS Code (profile Dev)", install.user_dir / "profiles" / "dev" / "settings.json") + ] + + def test_rejects_absolute_parent_and_resolved_outside_profile_locations(self, tmp_path): + profiles = [ + { + "name": "Absolute", + "location": str(tmp_path / "outside"), + "useDefaultFlags": {"extensions": True}, + }, + {"name": "Parent", "location": "../outside", "useDefaultFlags": {"extensions": True}}, + { + "name": "Nested parent", + "location": "nested/../safe", + "useDefaultFlags": {"extensions": True}, + }, + {"name": "Symlink", "location": "link", "useDefaultFlags": {"extensions": True}}, + ] + install = _install(tmp_path, profiles=profiles) + outside = tmp_path / "outside" + outside.mkdir() + (install.user_dir / "profiles").mkdir(parents=True, exist_ok=True) + (install.user_dir / "profiles" / "link").symlink_to(outside, target_is_directory=True) + + assert vscode.claude_settings_targets(install) == [ + vscode.SettingsTarget("VS Code", install.settings_path) + ] + + def test_configure_writes_the_profile_settings(self, tmp_path, wrapper, messages): + profiles = [{"name": "Dev", "location": "dev"}] + install = _install(tmp_path, extension=None, listed=(), profiles=profiles) + _extensions_json(install.user_dir / "profiles" / "dev" / "extensions.json", self.CLAUDE) + + _configure(install) + + profile_settings = install.user_dir / "profiles" / "dev" / "settings.json" + assert json.loads(profile_settings.read_text(encoding="utf-8"))[WRAPPER_KEY] == WRAPPER + assert not install.settings_path.exists() + assert "profile Dev" in messages["success"][0] + + def test_unreadable_profile_list_falls_back_to_the_default_profile(self, tmp_path): + install = _install(tmp_path, listed=(self.CLAUDE,)) + storage = install.user_dir / "globalStorage" / "storage.json" + storage.parent.mkdir(parents=True) + storage.write_text("{not json", encoding="utf-8") + + assert self._names(install) == [("VS Code", install.settings_path)] + + +class TestConfigure: + def test_creates_settings_when_absent(self, tmp_path, wrapper, messages): + install = _install(tmp_path) + + _configure(install) + + assert _settings(install) == {WRAPPER_KEY: WRAPPER, LOGIN_KEY: True, MODE_KEY: "default"} + assert len(messages["success"]) == 1 and "Reload VS Code" in messages["success"][0] + + def test_keeps_other_settings_and_the_users_permission_mode(self, tmp_path, wrapper, messages): + install = _install(tmp_path) + _write_settings(install, {"editor.fontSize": 14, MODE_KEY: "acceptEdits"}) + + _configure(install) + + assert _settings(install) == { + "editor.fontSize": 14, + MODE_KEY: "acceptEdits", + WRAPPER_KEY: WRAPPER, + LOGIN_KEY: True, + } + + def test_second_run_changes_nothing(self, tmp_path, monkeypatch, wrapper, messages): + install = _install(tmp_path) + _configure(install) + writes = [] + monkeypatch.setattr(config_io, "write_text_file", lambda path, text: writes.append(path)) + + _configure(install) + + assert writes == [] + assert messages["note"] == [ + "VS Code: the Claude Code extension already uses Unity Gateway." + ] + + def test_leaves_settings_with_comments_untouched(self, tmp_path, wrapper, messages): + install = _install(tmp_path) + original = '{\n // my font\n "editor.fontSize": 14,\n}\n' + _write_settings(install, original) + + _configure(install) + + assert install.settings_path.read_text(encoding="utf-8") == original + assert len(messages["warning"]) == 1 + assert f'"{WRAPPER_KEY}": "{WRAPPER}",' in messages["warning"][0] + + def test_does_not_replace_another_wrapper(self, tmp_path, wrapper, messages): + install = _install(tmp_path) + _write_settings(install, {WRAPPER_KEY: "C:/tools/my-wrapper.exe"}) + + _configure(install) + + assert _settings(install) == {WRAPPER_KEY: "C:/tools/my-wrapper.exe"} + assert "my-wrapper.exe" in messages["warning"][0] + + def test_moves_its_own_wrapper_to_the_current_install(self, tmp_path, wrapper, messages): + install = _install(tmp_path) + _write_settings(install, {WRAPPER_KEY: "C:/old/bin/ug-claude-vscode.exe", LOGIN_KEY: True}) + + _configure(install) + + assert _settings(install)[WRAPPER_KEY] == WRAPPER + + def test_bom_prefixed_settings_are_read(self, tmp_path, wrapper, messages): + install = _install(tmp_path) + install.settings_path.parent.mkdir(parents=True) + install.settings_path.write_bytes(b'\xef\xbb\xbf{"editor.fontSize": 14}') + + _configure(install) + + assert _settings(install)["editor.fontSize"] == 14 + assert _settings(install)[WRAPPER_KEY] == WRAPPER + + def test_no_extension_is_silent(self, tmp_path, monkeypatch, messages): + install = _install(tmp_path, extension=None) + monkeypatch.setattr(vscode, "wrapper_executable", lambda: pytest.fail("not needed")) + + _configure(install) + + assert not install.settings_path.exists() + assert messages == {"warning": [], "note": [], "success": []} + + def test_missing_wrapper_warns_and_writes_nothing(self, tmp_path, monkeypatch, messages): + install = _install(tmp_path) + monkeypatch.setattr(vscode, "wrapper_executable", lambda: None) + + _configure(install) + + assert not install.settings_path.exists() + assert "reinstall Unity Gateway" in messages["warning"][0] + + def test_dry_run_writes_nothing(self, tmp_path, monkeypatch, wrapper, messages): + install = _install(tmp_path) + monkeypatch.setattr(config_io, "_dry_run", True) + + _configure(install) + + assert not install.settings_path.exists() + assert not (config_io.APP_DIR / "vscode-claude-extension.json").exists() + + def test_configures_every_install_with_the_extension(self, tmp_path, wrapper, messages): + stable = _install(tmp_path, name="VS Code") + insiders = _install(tmp_path, name="VS Code Insiders") + without = _install(tmp_path, name="No Claude extension", extension=None) + + vscode.configure_claude_extension(installs=lambda: [stable, insiders, without]) + + assert _settings(stable)[WRAPPER_KEY] == WRAPPER + assert _settings(insiders)[WRAPPER_KEY] == WRAPPER + assert not without.settings_path.exists() + + def test_persists_the_revert_record_before_settings_write( + self, tmp_path, monkeypatch, wrapper, messages + ): + install = _install(tmp_path) + events: list[str] = [] + monkeypatch.setattr(vscode, "_write_record", lambda _record: events.append("record")) + monkeypatch.setattr( + config_io, + "write_text_file", + lambda _path, _text: events.append("settings"), + ) + + _configure(install) + + assert events.index("record") < events.index("settings") + + def test_continues_after_one_profile_write_fails_and_keeps_its_record( + self, tmp_path, monkeypatch, wrapper, messages + ): + stable = _install(tmp_path, name="VS Code") + insiders = _install(tmp_path, name="VS Code Insiders") + write_text_file = config_io.write_text_file + + def write(path, text): + if path == insiders.settings_path: + raise RuntimeError("read-only") + write_text_file(path, text) + + monkeypatch.setattr(config_io, "write_text_file", write) + + vscode.configure_claude_extension(installs=lambda: [stable, insiders]) + + assert _settings(stable)[WRAPPER_KEY] == WRAPPER + assert not insiders.settings_path.exists() + assert "VS Code Insiders" in messages["warning"][0] + assert set(_record()) == {str(stable.settings_path), str(insiders.settings_path)} + + +class TestRevert: + def test_removes_only_what_ug_added(self, tmp_path, wrapper, messages): + install = _install(tmp_path) + _write_settings(install, {"editor.fontSize": 14, LOGIN_KEY: False}) + _configure(install) + + assert vscode.revert_claude_extension() == "restored" + + assert _settings(install) == {"editor.fontSize": 14, LOGIN_KEY: False} + assert not (config_io.APP_DIR / "vscode-claude-extension.json").exists() + + def test_keeps_values_changed_since(self, tmp_path, wrapper, messages): + install = _install(tmp_path) + _configure(install) + doc = _settings(install) + doc[MODE_KEY] = "plan" + _write_settings(install, doc) + + vscode.revert_claude_extension() + + assert _settings(install) == {MODE_KEY: "plan"} + + def test_restores_an_older_ug_wrapper_path(self, tmp_path, wrapper, messages): + install = _install(tmp_path) + _write_settings(install, {WRAPPER_KEY: "C:/old/bin/ug-claude-vscode.exe"}) + _configure(install) + + vscode.revert_claude_extension() + + assert _settings(install)[WRAPPER_KEY] == "C:/old/bin/ug-claude-vscode.exe" + + def test_retains_a_jsonc_record_and_reports_manual_cleanup(self, tmp_path, wrapper, messages): + install = _install(tmp_path) + _configure(install) + _write_settings(install, '{\n // user comment\n "editor.fontSize": 14,\n}\n') + + assert vscode.revert_claude_extension() == "unchanged (manual cleanup needed)" + + assert set(_record()) == {str(install.settings_path)} + assert "retained its revert record" in messages["warning"][0] + + def test_restores_other_profiles_when_one_revert_write_fails( + self, tmp_path, monkeypatch, wrapper, messages + ): + stable = _install(tmp_path, name="VS Code") + insiders = _install(tmp_path, name="VS Code Insiders") + vscode.configure_claude_extension(installs=lambda: [stable, insiders]) + write_text_file = config_io.write_text_file + + def write(path, text): + if path == insiders.settings_path: + raise RuntimeError("read-only") + write_text_file(path, text) + + monkeypatch.setattr(config_io, "write_text_file", write) + + assert vscode.revert_claude_extension() == "partially restored" + + assert _settings(stable) == {} + assert _settings(insiders)[WRAPPER_KEY] == WRAPPER + assert set(_record()) == {str(insiders.settings_path)} + assert "couldn't restore" in messages["warning"][0] + + def test_nothing_recorded(self): + assert vscode.revert_claude_extension() == "unchanged" + + +class TestWrapperExecutable: + def test_prefers_path_when_no_sibling_exists(self, monkeypatch, tmp_path): + monkeypatch.setattr(vscode.sys, "argv", [str(tmp_path / "ug")]) + monkeypatch.setattr(vscode.shutil, "which", lambda name: f"/bin/{name}") + assert vscode.wrapper_executable() == "/bin/ug-claude-vscode" + + def test_prefers_sibling_to_path(self, monkeypatch, tmp_path): + suffix = ".exe" if vscode.os.name == "nt" else "" + active_ug = tmp_path / f"ug{suffix}" + sibling = tmp_path / f"ug-claude-vscode{suffix}" + active_ug.write_text("", encoding="utf-8") + sibling.write_text("", encoding="utf-8") + monkeypatch.setattr(vscode.sys, "argv", [str(active_ug)]) + monkeypatch.setattr(vscode.shutil, "which", lambda name: f"/bin/{name}") + + assert Path(vscode.wrapper_executable()) == sibling + + def test_falls_back_to_next_to_ug(self, monkeypatch, tmp_path): + suffix = ".exe" if vscode.os.name == "nt" else "" + (tmp_path / f"ug-claude-vscode{suffix}").write_text("", encoding="utf-8") + monkeypatch.setattr(vscode.shutil, "which", lambda name: None) + monkeypatch.setattr(vscode.sys, "argv", [str(tmp_path / f"ug{suffix}")]) + + assert Path(vscode.wrapper_executable()) == tmp_path / f"ug-claude-vscode{suffix}" + + def test_not_installed(self, monkeypatch, tmp_path): + monkeypatch.setattr(vscode.shutil, "which", lambda name: None) + monkeypatch.setattr(vscode.sys, "argv", [str(tmp_path / "ug")]) + + assert vscode.wrapper_executable() is None diff --git a/tests/test_vscode_wrapper.py b/tests/test_vscode_wrapper.py new file mode 100644 index 000000000..f680e80d7 --- /dev/null +++ b/tests/test_vscode_wrapper.py @@ -0,0 +1,133 @@ +"""Tests for ``ug-claude-vscode`` (ucode.vscode_wrapper), the VS Code extension's Claude launcher.""" + +from __future__ import annotations + +import json +import shutil +import sys +import tomllib +from pathlib import Path + +import pytest + +from ucode import vscode_wrapper +from ucode.agents import claude + + +@pytest.fixture +def settings(monkeypatch, tmp_path): + path = tmp_path / "ucode-settings.json" + path.write_text("{}", encoding="utf-8") + monkeypatch.setattr(vscode_wrapper, "UG_CLAUDE_SETTINGS", path) + return path + + +@pytest.fixture +def calls(monkeypatch): + recorded: list[list[str]] = [] + + def run(argv, **kwargs): + recorded.append(argv) + assert kwargs == {"check": False} + return type("Completed", (), {"returncode": 7})() + + monkeypatch.setattr(vscode_wrapper.subprocess_cross_os, "run", run) + return recorded + + +def _run(monkeypatch, *args: str) -> int: + monkeypatch.setattr(vscode_wrapper.sys, "argv", ["ug-claude-vscode", *args]) + return vscode_wrapper.main() + + +def test_reads_the_settings_file_ug_writes(): + assert vscode_wrapper.UG_CLAUDE_SETTINGS == claude.CLAUDE_SETTINGS_PATH + + +def test_launches_the_binary_the_extension_passes(monkeypatch, tmp_path, settings, calls): + binary = tmp_path / "claude.exe" + binary.write_text("", encoding="utf-8") + + assert _run(monkeypatch, str(binary), "--output-format", "stream-json") == 7 + + assert calls == [[str(binary), "--settings", str(settings), "--output-format", "stream-json"]] + + +def test_runs_a_javascript_build_with_node(monkeypatch, tmp_path, settings, calls): + script = tmp_path / "cli.js" + script.write_text("", encoding="utf-8") + monkeypatch.setattr(vscode_wrapper.shutil, "which", lambda name: f"/bin/{name}") + + _run(monkeypatch, str(script), "-p", "hi") + + assert calls == [["/bin/node", str(script), "--settings", str(settings), "-p", "hi"]] + + +def test_falls_back_to_claude_on_path(monkeypatch, settings, calls): + monkeypatch.setattr(vscode_wrapper.shutil, "which", lambda name: f"/bin/{name}") + + _run(monkeypatch, "-p", "hi") + + assert calls == [["/bin/claude", "--settings", str(settings), "-p", "hi"]] + + +def test_missing_settings_points_at_ug_configure(monkeypatch, tmp_path, calls): + monkeypatch.setattr(vscode_wrapper, "UG_CLAUDE_SETTINGS", tmp_path / "missing.json") + + with pytest.raises(SystemExit, match="run `ug configure` first"): + _run(monkeypatch, "-p", "hi") + assert calls == [] + + +def test_no_claude_anywhere(monkeypatch, settings, calls): + monkeypatch.setattr(vscode_wrapper.shutil, "which", lambda name: None) + + with pytest.raises(SystemExit, match="`claude` is not on PATH"): + _run(monkeypatch, "-p", "hi") + + +@pytest.mark.parametrize( + "settings_override", + [("--settings", "{}"), ("--settings={}",)], +) +def test_rejects_a_second_settings_flag( + monkeypatch, tmp_path, settings, calls, capsys, settings_override +): + binary = tmp_path / "claude" + binary.write_text("", encoding="utf-8") + + result = _run(monkeypatch, str(binary), *settings_override) + + captured = capsys.readouterr() + assert result == 2 + assert captured.out == "" # stdout is the extension's protocol channel + assert "Remove that argument and retry" in captured.err + assert calls == [] + + +def test_log_records_each_launch(monkeypatch, tmp_path, settings, calls): + log = tmp_path / "launch.log" + monkeypatch.setenv(vscode_wrapper.LOG_ENV_VAR, str(log)) + binary = tmp_path / "claude" + binary.write_text("", encoding="utf-8") + + _run(monkeypatch, str(binary), "-p", "hi") + + assert json.loads(log.read_text(encoding="utf-8")) == [ + "ug-claude-vscode", + str(binary), + "-p", + "hi", + ] + + +def test_is_declared_as_a_console_script(): + pyproject = tomllib.loads( + (Path(__file__).parent.parent / "pyproject.toml").read_text(encoding="utf-8") + ) + assert pyproject["project"]["scripts"]["ug-claude-vscode"] == "ucode.vscode_wrapper:main" + + +def test_console_script_is_installed_next_to_ug(): + bin_dir = Path(sys.executable).parent + assert shutil.which("ug-claude-vscode", path=str(bin_dir)) is not None From 1d8d6668e6ca304f23eb31feb77897110502efc4 Mon Sep 17 00:00:00 2001 From: Girish Shankar <12990248+GirishS2093@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:58:49 -0400 Subject: [PATCH 2/2] Point the Codex VS Code extension at Unity Gateway The Codex extension runs its own bundled Codex, which reads only Codex's config files and never the ucode.config.toml layer `ug codex` passes on the command line. On Linux and macOS the machine-wide /etc/codex/managed_config.toml already carries ug's provider to it; on Windows (and when that privileged write is skipped) nothing does, so it asked for a ChatGPT sign-in. When Codex is configured and any VS Code profile or the VS Code Server has the extension (openai.chatgpt), write the keys ug puts in the machine-wide file (model_provider, the admin's default model, [model_providers.Databricks]) into ~/.codex/config.toml, unless the machine-wide file already points at this gateway: - The copy waits 30 s for `ug auth-token`: the extension's Codex starts it while itself starting, and on Windows that took 8-9 s, so every request timed out at 5 s. - The admin's default model is pinned; otherwise Codex starts on the catalog's first entry, which the gateway may reject. - A foreign model_provider is left alone with a warning; identical hand-added keys are taken over; --dry-run writes nothing. - `ug revert` removes only those keys and restores a previous model (recorded in ~/.ucode/vscode-codex-extension.json). Co-Authored-By: Claude Opus 5.5 --- README.md | 19 ++ src/ucode/agents/codex.py | 376 +++++++++++++++++++++++++++++++++++++- src/ucode/cli.py | 2 + src/ucode/vscode.py | 37 +++- tests/README.md | 2 +- tests/test_agent_codex.py | 306 +++++++++++++++++++++++++++++++ tests/test_cli.py | 19 ++ tests/test_vscode.py | 35 ++++ 8 files changed, 782 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index a13cf464c..803613248 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,24 @@ In the extension you get the gateway, automatic token refresh, the admin's model admin changes models. To see how the extension launches Claude, set `UG_CLAUDE_VSCODE_LOG=` through the `claudeCode.environmentVariables` setting. +## Codex in VS Code + +The Codex VS Code extension (`openai.chatgpt`) runs its own copy of Codex, which reads +Codex's own config files but not `~/.codex/ucode.config.toml`, so on its own it asks for +a ChatGPT sign-in. On Windows, when `ug configure` finds the extension, it writes gateway +settings to `~/.codex/config.toml`: `model_provider = "Databricks"`, the admin's default +`model`, and `[model_providers.Databricks]`. Plain `codex` uses them too. It does not write +this fallback on macOS or Linux. + +Reload VS Code afterwards. `ug` leaves `config.toml` alone when it already uses another +`model_provider`. `ug revert` removes only the keys `ug` added and restores a `model` you +had before. + +In the extension you get the gateway, automatic token refresh, the admin's model list and +`ug`'s MCP servers. Smart routing needs `ug codex`. On Windows the extension asks to set up +its Agent sandbox the first time; choose "Continue without administrator access" if you +can't approve the admin prompt. It also needs an open folder. + ## MCP Servers Register Databricks MCP servers for configured MCP-capable agents. Cursor Agent @@ -264,6 +282,7 @@ unchanged. Native daemon/background propagation of the plugin remains unverified | 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) | | Claude Code VS Code extension | the three `claudeCode.*` keys above in each profile's VS Code `settings.json`, and `~/.ucode/vscode-claude-extension.json` (what `ug` changed, for `ug revert`) | +| Codex VS Code extension (Windows) | `model_provider`, `model` and `[model_providers.Databricks]` in `~/.codex/config.toml`, and `~/.ucode/vscode-codex-extension.json` (what `ug` changed, for `ug revert`) | | 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` | | GitHub Copilot CLI | `~/.copilot/ucode.env`, `~/.copilot/ucode-mcp-config.json` | diff --git a/src/ucode/agents/codex.py b/src/ucode/agents/codex.py index 75606811d..f2fc14328 100644 --- a/src/ucode/agents/codex.py +++ b/src/ucode/agents/codex.py @@ -9,13 +9,13 @@ import signal import tempfile import threading -from collections.abc import Callable +from collections.abc import Callable, Mapping, MutableMapping from pathlib import Path import tomlkit from tomlkit.exceptions import ParseError -from ucode import gateway_proxy +from ucode import config_io, gateway_proxy, vscode from ucode.codex_config import ( catalog_slugs, codex_config_args, @@ -80,7 +80,7 @@ from ucode.smart_routing.routing import configured_router_name from ucode.state import get_provider_service, is_tool_managed, mark_tool_managed, save_state from ucode.telemetry import agent_version, ug_version -from ucode.ui import print_warning_err +from ucode.ui import print_note, print_success, print_warning, print_warning_err from .args import LaunchOptions from .codex_catalog import prepare_codex_catalog, validate_codex_catalog @@ -496,6 +496,15 @@ def compose(base: dict, *, include_catalog: bool = True) -> dict: _set_provider_header(base, None) return base + extension_gateway = None + if _is_windows(): + extension_gateway = copy.deepcopy(overlay) + extension_provider = _gateway_provider(extension_gateway) + if extension_provider is not None: + headers = extension_provider.get("http_headers") + if isinstance(headers, dict): + headers.pop(SMART_ROUTER_RECIPE_HEADER, None) + if catalog is not None: sync_app_model_catalog(catalog) elif not is_dry_run(): @@ -512,6 +521,8 @@ def compose(base: dict, *, include_catalog: bool = True) -> dict: ) write_toml_file(CODEX_CONFIG_PATH, doc) _reconcile_managed_config(state, lambda base: compose(base, include_catalog=False)) + if extension_gateway is not None: + configure_vscode_extension(extension_gateway) state = mark_tool_managed(state, "codex", MANAGED_KEYS) save_state(state) return state @@ -894,8 +905,8 @@ 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() +def _read_app_config(path: Path | None = None) -> tomlkit.TOMLDocument: + path = path or _legacy_config_path() try: return tomlkit.parse(path.read_text(encoding="utf-8")) except FileNotFoundError: @@ -971,6 +982,361 @@ def sync_app_model_catalog(catalog: dict) -> None: _print_app_catalog_restart_notice() +# The Codex VS Code extension runs its own bundled Codex, which reads only Codex's config files and +# never the ucode.config.toml layer that `ug codex` passes on the command line. On Windows, ug copies +# the gateway settings into the user's config.toml. +_VSCODE_RECORD_NAME = "vscode-codex-extension.json" +# The extension's Codex runs `ug auth-token` while it is itself starting; on Windows that took +# 8-9 s, past the usual 5 s wait, so every request failed. The copy for it waits longer. +_VSCODE_AUTH_TIMEOUT_MS = 30_000 +_MISSING = object() + + +def _is_windows() -> bool: + return os.name == "nt" + + +def _vscode_record_path() -> Path: + return config_io.APP_DIR / _VSCODE_RECORD_NAME + + +def _read_vscode_record() -> dict: + data = read_json_safe(_vscode_record_path()) + return data if isinstance(data, dict) else {} + + +def _write_vscode_record(record: dict) -> None: + if is_dry_run(): + return + path = _vscode_record_path() + if record.get("set"): + config_io.atomic_write_json(path, record) + else: + path.unlink(missing_ok=True) + + +def _plain(value: object) -> object: + unwrap = getattr(value, "unwrap", None) + if callable(unwrap): + return _plain(unwrap()) + if isinstance(value, Mapping): + return {key: _plain(child) for key, child in value.items()} + if isinstance(value, list): + return [_plain(child) for child in value] + return value + + +def _gateway_provider(doc: dict) -> dict | None: + providers = _plain(doc.get("model_providers")) + provider = providers.get(CODEX_MODEL_PROVIDER_NAME) if isinstance(providers, dict) else None + provider = _plain(provider) + return provider if isinstance(provider, dict) else None + + +def _read_path(doc: Mapping, path: tuple[str, ...]) -> object: + current: object = doc + for key in path: + if not isinstance(current, Mapping) or key not in current: + return _MISSING + current = current[key] + return _plain(current) + + +def _set_path(doc: MutableMapping, path: tuple[str, ...], value: object) -> None: + current: MutableMapping = doc + for key in path[:-1]: + child = current.get(key) + if not isinstance(child, Mapping): + child = tomlkit.table() + current[key] = child + current = child # type: ignore[assignment] + current[path[-1]] = copy.deepcopy(value) + + +def _delete_path(doc: MutableMapping, path: tuple[str, ...]) -> bool: + current: MutableMapping = doc + parents: list[tuple[MutableMapping, str]] = [] + for key in path[:-1]: + child = current.get(key) + if not isinstance(child, Mapping) or key not in current: + return False + parents.append((current, key)) + current = child # type: ignore[assignment] + if path[-1] not in current: + return False + del current[path[-1]] + for parent, key in reversed(parents): + child = parent.get(key) + if isinstance(child, Mapping) and not child: + del parent[key] + return True + + +def _record_get(record: Mapping, path: tuple[str, ...]) -> object: + current: object = record + for key in path: + if not isinstance(current, Mapping) or key not in current: + return _MISSING + current = current[key] + return current + + +def _record_set(record: dict, path: tuple[str, ...], value: object) -> None: + current = record + for key in path[:-1]: + child = current.get(key) + if not isinstance(child, dict): + child = {} + current[key] = child + current = child + current[path[-1]] = copy.deepcopy(value) + + +def _record_delete(record: dict, path: tuple[str, ...]) -> None: + current = record + parents: list[tuple[dict, str]] = [] + for key in path[:-1]: + child = current.get(key) + if not isinstance(child, dict) or key not in current: + return + parents.append((current, key)) + current = child + if path[-1] not in current: + return + del current[path[-1]] + for parent, key in reversed(parents): + child = parent.get(key) + if isinstance(child, dict) and not child: + del parent[key] + + +def _record_leaf_paths(value: Mapping, prefix: tuple[str, ...] = ()): + for key, child in value.items(): + path = (*prefix, str(key)) + if isinstance(child, Mapping): + yield from _record_leaf_paths(child, path) + else: + yield path, child + + +def _record_change(record: dict, path: tuple[str, ...], current: object, desired: object) -> None: + set_values = record.setdefault("set", {}) + previous_values = record.setdefault("previous", {}) + if not isinstance(set_values, dict) or not isinstance(previous_values, dict): + set_values = {} + previous_values = {} + record["set"] = set_values + record["previous"] = previous_values + + previous_set = _record_get(set_values, path) + previous_value = _record_get(previous_values, path) + # A second configure after a user edit takes ownership of the newly written value, but + # revert must restore that edit rather than the value from an older configure. + if previous_set is not _MISSING and current is not _MISSING and current != previous_set: + _record_set(previous_values, path, current) + elif previous_set is _MISSING and previous_value is _MISSING and current is not _MISSING: + _record_set(previous_values, path, current) + _record_set(set_values, path, desired) + + +def _record_drop(record: dict, doc: MutableMapping, path: tuple[str, ...]) -> bool: + set_values = record.get("set") + if not isinstance(set_values, dict): + return False + desired = _record_get(set_values, path) + if desired is _MISSING: + return False + current = _read_path(doc, path) + previous_values = record.get("previous") + previous = _record_get(previous_values, path) if isinstance(previous_values, dict) else _MISSING + if current == desired: + if previous is _MISSING: + changed = _delete_path(doc, path) + else: + _set_path(doc, path, previous) + changed = True + else: + changed = False + _record_delete(set_values, path) + if isinstance(previous_values, dict): + _record_delete(previous_values, path) + return changed + + +def _restore_vscode_record(doc: MutableMapping, record: Mapping) -> bool: + set_values = record.get("set") + if not isinstance(set_values, Mapping): + return False + previous_values = record.get("previous") + previous_values = previous_values if isinstance(previous_values, Mapping) else {} + + # If the user selected another provider, leave the complete file alone. This also protects + # a pre-existing Databricks block when only part of it was owned by ug. + model_provider_path = ("model_provider",) + recorded_provider = _record_get(set_values, model_provider_path) + current_provider = _read_path(doc, model_provider_path) + provider_paths = any( + path[:1] == ("model_providers",) for path, _ in _record_leaf_paths(set_values) + ) + if recorded_provider is not _MISSING and current_provider != recorded_provider: + return False + if ( + recorded_provider is _MISSING + and provider_paths + and current_provider + not in ( + _MISSING, + CODEX_MODEL_PROVIDER_NAME, + ) + ): + return False + + changed = False + for path, desired in _record_leaf_paths(set_values): + current = _read_path(doc, path) + if current is _MISSING or current != desired: + continue # The user changed or removed this value; leave it alone. + previous = _record_get(previous_values, path) + if previous is _MISSING: + changed = _delete_path(doc, path) or changed + else: + _set_path(doc, path, previous) + changed = True + return changed + + +def configure_vscode_extension(gateway: dict) -> None: + """Point the Codex VS Code extension at Unity Gateway. + + ``gateway`` holds ``model_provider``, the admin's default ``model`` if any, and + ``[model_providers.Databricks]``. Silent outside Windows or when VS Code has no Codex extension. + """ + if not _is_windows() or not vscode.has_codex_extension(): + return + provider = _gateway_provider(gateway) + if gateway.get("model_provider") != CODEX_MODEL_PROVIDER_NAME or provider is None: + return + auth = provider.get("auth") + if isinstance(auth, dict) and auth.get("timeout_ms", 0) < _VSCODE_AUTH_TIMEOUT_MS: + provider = copy.deepcopy(provider) + provider["auth"] = {**auth, "timeout_ms": _VSCODE_AUTH_TIMEOUT_MS} + path = LEGACY_CODEX_CONFIG_PATH + try: + doc = _read_app_config(path) + except RuntimeError as exc: + print_warning( + f"{exc}. Fix it and re-run `ug configure` to use the Codex VS Code extension." + ) + return + current_provider = _read_path(doc, ("model_provider",)) + if current_provider not in (_MISSING, None, CODEX_MODEL_PROVIDER_NAME): + print_warning( + f"{path} already uses the model provider {current_provider}; ug left it alone, so the " + "Codex VS Code extension won't use Unity Gateway. To use it, remove model_provider " + "from that file and re-run `ug configure`." + ) + return + + providers = doc.get("model_providers") + if providers is not None and not isinstance(providers, Mapping): + print_warning( + f"{path} has a non-table model_providers value; ug left it alone, so the Codex VS Code " + "extension won't use Unity Gateway. Fix it and re-run `ug configure`." + ) + return + existing_provider = ( + providers.get(CODEX_MODEL_PROVIDER_NAME) if isinstance(providers, Mapping) else None + ) + if existing_provider is not None and not isinstance(existing_provider, Mapping): + print_warning( + f"{path} has a non-table model_providers.{CODEX_MODEL_PROVIDER_NAME} value; ug left it " + "alone, so the Codex VS Code extension won't use Unity Gateway. Fix it and re-run " + "`ug configure`." + ) + return + for nested_key in ("auth", "http_headers"): + nested = ( + existing_provider.get(nested_key) if isinstance(existing_provider, Mapping) else None + ) + if ( + isinstance(provider.get(nested_key), Mapping) + and nested is not None + and not isinstance(nested, Mapping) + ): + print_warning( + f"{path} has a non-table model_providers.{CODEX_MODEL_PROVIDER_NAME}." + f"{nested_key} value; ug left it alone, so the Codex VS Code extension " + "won't use Unity Gateway. Fix it and re-run `ug configure`." + ) + return + + record = _read_vscode_record() + if record.get("path") != str(path) or not isinstance(record.get("set"), dict): + record = {} + record["path"] = str(path) + changed = False + if current_provider != CODEX_MODEL_PROVIDER_NAME: + _record_change(record, ("model_provider",), current_provider, CODEX_MODEL_PROVIDER_NAME) + _set_path(doc, ("model_provider",), CODEX_MODEL_PROVIDER_NAME) + changed = True + + # Merge only gateway-owned provider leaves. A user may keep custom Codex provider options, + # headers, and auth fields alongside the values needed by the extension. + for provider_path, desired in _record_leaf_paths( + provider, ("model_providers", CODEX_MODEL_PROVIDER_NAME) + ): + current = _read_path(doc, provider_path) + if current == desired: + continue + _record_change(record, provider_path, current, desired) + _set_path(doc, provider_path, desired) + changed = True + + # Without a pinned model Codex boots on its catalog's first entry, which the gateway may + # reject; ug pins the admin's default exactly as it does for `ug codex`. + model = _plain(gateway.get("model")) + if isinstance(model, str) and model: + current_model = _read_path(doc, ("model",)) + if current_model != model: + _record_change(record, ("model",), current_model, model) + _set_path(doc, ("model",), model) + changed = True + else: + changed = _record_drop(record, doc, ("model",)) or changed + + if is_dry_run(): + return + _write_vscode_record(record) + if changed: + write_toml_file(path, doc) + if changed: + print_success( + f"VS Code: Codex extension set to use Unity Gateway ({path}; plain `codex` uses it " + "too). Reload VS Code to apply." + ) + else: + print_note("VS Code: the Codex extension already uses Unity Gateway.") + + +def revert_vscode_extension() -> str: + """Remove the gateway keys ug added for the Codex VS Code extension. Returns a summary.""" + record = _read_vscode_record() + if not record: + return "unchanged" + path = Path(record.get("path") or LEGACY_CODEX_CONFIG_PATH) + try: + doc = _read_app_config(path) + except RuntimeError as exc: + print_warning_err(str(exc)) + return "unchanged" + changed = _restore_vscode_record(doc, record) + if changed: + write_toml_file(path, doc) + if not is_dry_run(): + _vscode_record_path().unlink(missing_ok=True) + return "restored" if changed else "unchanged" + + 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 diff --git a/src/ucode/cli.py b/src/ucode/cli.py index 95b1e6535..90e6fa2e2 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -1289,6 +1289,7 @@ def revert() -> int: pi_settings_restored = restore_file( PI_SETTINGS_PATH, PI_SETTINGS_BACKUP_PATH, bool(managed_configs.get("pi")) ) + codex_vscode_result = codex_agent.revert_vscode_extension() # Older Codex (< 0.134.0) had ucode edit the shared ~/.codex/config.toml in # place; restoring the per-profile file above does not undo that. legacy_codex_stripped = revert_legacy_shared_config() @@ -1305,6 +1306,7 @@ def revert() -> int: print_kv("Codex OS-managed settings", codex_managed_result) print_kv("Pi settings", "restored" if pi_settings_restored else "unchanged") print_kv("VS Code Claude Code extension", vscode_result) + print_kv("VS Code Codex extension", codex_vscode_result) for client, spec in MCP_CLIENTS.items(): print_kv( f"{spec['display']} MCP config", diff --git a/src/ucode/vscode.py b/src/ucode/vscode.py index b9aa07fd2..e9c74c0fb 100644 --- a/src/ucode/vscode.py +++ b/src/ucode/vscode.py @@ -8,6 +8,9 @@ Only strict-JSON ``settings.json`` files are edited: rewriting one that has comments or trailing commas would drop them, so those get the settings printed for manual entry. + +The Codex extension needs no VS Code settings (it reads Codex's own config files); ug only asks +:func:`has_codex_extension` whether to point those files at the gateway (see ``agents/codex.py``). """ from __future__ import annotations @@ -24,6 +27,7 @@ from ucode.ui import print_note, print_success, print_warning CLAUDE_EXTENSION_ID = "anthropic.claude-code" +CODEX_EXTENSION_ID = "openai.chatgpt" WRAPPER_COMMAND = "ug-claude-vscode" WRAPPER_SETTING = "claudeCode.claudeProcessWrapper" DISABLE_LOGIN_SETTING = "claudeCode.disableLoginPrompt" @@ -50,7 +54,7 @@ class VSCodeInstall: @dataclass(frozen=True) class SettingsTarget: - """A settings.json whose VS Code profile has the Claude Code extension.""" + """A settings.json whose VS Code profile has the extension being looked for.""" name: str settings_path: Path @@ -84,7 +88,11 @@ def user_dir(product: str) -> Path: def has_claude_extension(install: VSCodeInstall) -> bool: """Whether a Claude Code extension folder exists in ``install`` (any installed version).""" - prefix = f"{CLAUDE_EXTENSION_ID}-" + return _has_extension_folder(install, CLAUDE_EXTENSION_ID) + + +def _has_extension_folder(install: VSCodeInstall, extension_id: str) -> bool: + prefix = f"{extension_id}-" try: return any( entry.is_dir() and entry.name.lower().startswith(prefix) @@ -94,7 +102,7 @@ def has_claude_extension(install: VSCodeInstall) -> bool: return False -def _lists_claude_extension(extensions_json: Path) -> bool | None: +def _lists_extension(extensions_json: Path, extension_id: str) -> bool | None: """Whether a profile's ``extensions.json`` lists the extension; None when it can't be read.""" try: entries = json.loads(extensions_json.read_text(encoding="utf-8-sig")) @@ -105,7 +113,7 @@ def _lists_claude_extension(extensions_json: Path) -> bool | None: return any( isinstance(entry, dict) and isinstance(entry.get("identifier"), dict) - and str(entry["identifier"].get("id", "")).lower() == CLAUDE_EXTENSION_ID + and str(entry["identifier"].get("id", "")).lower() == extension_id for entry in entries ) @@ -123,14 +131,27 @@ def _profiles(user_dir: Path) -> list[dict]: def claude_settings_targets(install: VSCodeInstall) -> list[SettingsTarget]: - """The settings.json of every profile in ``install`` that has the Claude Code extension. + """The settings.json of every profile in ``install`` that has the Claude Code extension.""" + return _extension_settings_targets(install, CLAUDE_EXTENSION_ID) + + +def has_codex_extension(installs: Callable[[], list[VSCodeInstall]] | None = None) -> bool: + """Whether any Windows VS Code profile has the Codex extension.""" + return any( + _extension_settings_targets(install, CODEX_EXTENSION_ID) + for install in (installs or vscode_installs)() + ) + + +def _extension_settings_targets(install: VSCodeInstall, extension_id: str) -> list[SettingsTarget]: + """The settings.json of every profile in ``install`` that has the extension. The default profile's extensions are listed in ``/extensions.json``; each other profile has its own ``extensions.json`` and ``settings.json`` under ``User/profiles//`` unless its ``useDefaultFlags`` shares the default profile's. """ - listed = _lists_claude_extension(install.extensions_dir / "extensions.json") - default_has = has_claude_extension(install) if listed is None else listed + listed = _lists_extension(install.extensions_dir / "extensions.json", extension_id) + default_has = _has_extension_folder(install, extension_id) if listed is None else listed targets: list[SettingsTarget] = [] if default_has: targets.append(SettingsTarget(install.name, install.settings_path)) @@ -147,7 +168,7 @@ def claude_settings_targets(install: VSCodeInstall) -> list[SettingsTarget]: has = ( default_has if flags.get("extensions") - else _lists_claude_extension(profile_dir / "extensions.json") is True + else _lists_extension(profile_dir / "extensions.json", extension_id) is True ) if not has: continue diff --git a/tests/README.md b/tests/README.md index c7225f204..c85cd5ae6 100644 --- a/tests/README.md +++ b/tests/README.md @@ -257,7 +257,7 @@ These are unit/component checks; they do not establish live sudo password-prompt | Claude/Codex interactive smart routing | First-prompt routing is covered by the `managed_fixture` banner journeys. The live subagent-only journeys spawn native children and invoke the installed Smart Router skill to verify routing on -> off -> on within one Claude/Codex session. Plugin-refresh survival, native daemon/background dispatch, interactive explicit-model bypass, and dedicated routing CI shards remain deferred. | | Full allow/deny tool-permission matrix | Not covered; onboarding/trust uses actual TUI choices | | Desktop Codex app, Isaac itself, auto-upgrades | Not covered by command forwarding or pinned-version tests | -| Claude Code VS Code extension | Windows-only unit/component tests (`test_vscode.py`, `test_vscode_wrapper.py`, CLI configure/revert wiring). No journey drives a live VS Code window; checked by hand against a real extension | +| Claude Code and Codex VS Code extensions | Windows-only unit/component tests (`test_vscode.py`, `test_vscode_wrapper.py`, `TestVSCodeExtension` in `test_agent_codex.py`, CLI configure/revert wiring). No journey drives a live VS Code window; checked by hand against a real extension | | Native macOS/Windows live TUI, managed settings, resize/signals | Windows fresh-install, CLI, local helpers, and the Claude headless gateway journey are advisory; Codex Windows CI is blocked on npm proxy access. Live PTY/TUI, managed settings, and signal behavior still need separate platform implementation and coverage | | Other agents | Live scope is Claude Code and Codex, plus a non-blocking OpenCode headless case | | OpenCode `--model` / `-m` | Unit/component tests cover raw CLI forwarding, configured model selection, launch arguments, rejection of unknown models before starting the native process, and the existing failure when no default model is available; no dedicated live integration journey | diff --git a/tests/test_agent_codex.py b/tests/test_agent_codex.py index c810a1b6f..a0d0a0aa4 100644 --- a/tests/test_agent_codex.py +++ b/tests/test_agent_codex.py @@ -2138,3 +2138,309 @@ def test_honors_codex_home_env(self, tmp_path, monkeypatch): "args": ["x"], } assert not default_path.exists() + + +_GATEWAY_URL = f"{WS}/ai-gateway/codex/v1" +_GATEWAY_TOML = f"""model_provider = "Databricks" +model = "system.ai.gpt-5-6-sol" + +[model_providers.Databricks] +name = "Databricks AI Gateway" +base_url = "{_GATEWAY_URL}" +wire_api = "responses" + +[model_providers.Databricks.auth] +command = "/home/me/.local/bin/ug" +args = ["auth-token", "--host", "{WS}", "--profile", "DEV"] +timeout_ms = 5000 +refresh_interval_ms = 900000 + +[model_providers.Databricks.http_headers] +User-Agent = "ucode/1.0 codex/0.155.0" +""" +_USER_CONFIG = """model_catalog_json = "/home/me/.ucode/codex-model-catalog.json" + +[hooks.state] + +[mcp_servers.webex] +command = "ug" +args = ["mcp-proxy"] +""" + + +def _gateway(*, base_url: str = _GATEWAY_URL, model: str | None = "system.ai.gpt-5-6-sol") -> dict: + import tomllib + + gateway = tomllib.loads(_GATEWAY_TOML.replace(_GATEWAY_URL, base_url)) + if model is None: + gateway.pop("model") + else: + gateway["model"] = model + return gateway + + +class TestVSCodeExtension: + """`ug configure` copies ug's gateway keys into config.toml for the Codex VS Code extension.""" + + @pytest.fixture + def paths(self, monkeypatch): + monkeypatch.setattr(codex.vscode, "has_codex_extension", lambda: True) + monkeypatch.setattr(codex, "_is_windows", lambda: True) + user = codex.CODEX_CONFIG_PATH.with_name("config.toml") + monkeypatch.setattr(codex, "LEGACY_CODEX_CONFIG_PATH", user) + user.parent.mkdir(parents=True, exist_ok=True) + user.write_text(_USER_CONFIG, encoding="utf-8") + return user + + @pytest.fixture + def messages(self, monkeypatch): + captured: dict[str, list[str]] = {"warning": [], "note": [], "success": []} + monkeypatch.setattr(codex, "print_warning", captured["warning"].append) + monkeypatch.setattr(codex, "print_note", captured["note"].append) + monkeypatch.setattr(codex, "print_success", captured["success"].append) + return captured + + @staticmethod + def _load(path: Path) -> dict: + import tomllib + + return tomllib.loads(path.read_text(encoding="utf-8")) + + def test_write_tool_config_does_not_configure_extension_off_windows(self, monkeypatch): + seen: list[dict] = [] + monkeypatch.setattr(codex, "configure_vscode_extension", seen.append) + monkeypatch.setattr(codex, "_is_windows", lambda: False) + monkeypatch.setattr(codex, "agent_version", lambda _binary: "0.155.0") + monkeypatch.setattr(codex, "save_state", lambda state: None) + + codex.write_tool_config( + {"workspace": WS, "codex_default_model": "system.ai.gpt-5-6-sol"}, + provider="main.team.openai", + ) + + assert seen == [] + + def test_windows_extension_keeps_provider_header(self, monkeypatch): + seen: list[dict] = [] + monkeypatch.setattr(codex, "configure_vscode_extension", seen.append) + monkeypatch.setattr(codex, "_is_windows", lambda: True) + monkeypatch.setattr(codex, "agent_version", lambda _binary: "0.155.0") + monkeypatch.setattr(codex, "save_state", lambda state: None) + + codex.write_tool_config( + { + "workspace": WS, + "codex_default_model": "system.ai.gpt-5-6-sol", + }, + provider="main.team.openai", + ) + + (provider_gateway,) = seen + provider = provider_gateway["model_providers"]["Databricks"] + assert provider["http_headers"][codex.MODEL_PROVIDER_SERVICE_HEADER] == "main.team.openai" + + def test_adds_gateway_keys_and_keeps_user_settings(self, paths, messages): + codex.configure_vscode_extension(_gateway()) + + doc = self._load(paths) + # Top-level keys must land above the file's tables to stay top-level. + assert doc["model_provider"] == "Databricks" + assert doc["model"] == "system.ai.gpt-5-6-sol" + expected = _gateway()["model_providers"]["Databricks"] + expected["auth"]["timeout_ms"] = 30_000 + assert doc["model_providers"]["Databricks"] == expected + assert doc["mcp_servers"]["webex"] == {"command": "ug", "args": ["mcp-proxy"]} + assert doc["model_catalog_json"] == "/home/me/.ucode/codex-model-catalog.json" + assert "hooks" in doc + assert len(messages["success"]) == 1 + assert "Codex extension set to use Unity Gateway" in messages["success"][0] + + def test_extension_copy_waits_longer_for_the_token(self, paths): + # The extension's Codex runs `ug auth-token` while starting up; 5 s wasn't enough. + codex.configure_vscode_extension(_gateway()) + assert self._load(paths)["model_providers"]["Databricks"]["auth"]["timeout_ms"] == 30_000 + + def test_custom_oauth_keeps_its_longer_wait(self, paths): + gateway = _gateway() + gateway["model_providers"]["Databricks"]["auth"]["timeout_ms"] = 180_000 + codex.configure_vscode_extension(gateway) + assert self._load(paths)["model_providers"]["Databricks"]["auth"]["timeout_ms"] == 180_000 + + def test_silent_without_the_extension(self, paths, monkeypatch, messages): + monkeypatch.setattr(codex.vscode, "has_codex_extension", lambda: False) + codex.configure_vscode_extension(_gateway()) + assert paths.read_text(encoding="utf-8") == _USER_CONFIG + assert not codex._vscode_record_path().exists() + assert messages == {"warning": [], "note": [], "success": []} + + def test_silent_without_a_gateway_provider(self, paths, messages): + codex.configure_vscode_extension({"model": "gpt-5.5"}) + assert paths.read_text(encoding="utf-8") == _USER_CONFIG + assert messages == {"warning": [], "note": [], "success": []} + + def test_non_windows_does_not_touch_extension_config(self, paths, monkeypatch): + monkeypatch.setattr(codex, "_is_windows", lambda: False) + + codex.configure_vscode_extension(_gateway()) + + assert paths.read_text(encoding="utf-8") == _USER_CONFIG + + def test_leaves_another_provider_alone(self, paths, messages): + original = 'model_provider = "azure"\n' + _USER_CONFIG + paths.write_text(original, encoding="utf-8") + codex.configure_vscode_extension(_gateway()) + assert paths.read_text(encoding="utf-8") == original + assert len(messages["warning"]) == 1 + assert "azure" in messages["warning"][0] + assert not codex._vscode_record_path().exists() + + def test_second_run_changes_nothing(self, paths, messages): + codex.configure_vscode_extension(_gateway()) + first = paths.read_text(encoding="utf-8") + codex.configure_vscode_extension(_gateway()) + assert paths.read_text(encoding="utf-8") == first + assert messages["note"] == ["VS Code: the Codex extension already uses Unity Gateway."] + + def test_follows_a_workspace_change(self, paths): + codex.configure_vscode_extension(_gateway()) + other = "https://other.databricks.com/ai-gateway/codex/v1" + codex.configure_vscode_extension(_gateway(base_url=other)) + assert self._load(paths)["model_providers"]["Databricks"]["base_url"] == other + + def test_dry_run_writes_nothing(self, paths, monkeypatch, messages): + from ucode import config_io + + monkeypatch.setattr(config_io, "_dry_run", True) + codex.configure_vscode_extension(_gateway()) + assert paths.read_text(encoding="utf-8") == _USER_CONFIG + assert not codex._vscode_record_path().exists() + assert messages["success"] == [] + + def test_unreadable_config_is_left_alone(self, paths, messages): + paths.write_text("model = [broken\n", encoding="utf-8") + codex.configure_vscode_extension(_gateway()) + assert paths.read_text(encoding="utf-8") == "model = [broken\n" + assert len(messages["warning"]) == 1 + + @pytest.mark.parametrize("nested", ["auth", "http_headers"]) + def test_non_table_nested_provider_is_left_untouched(self, paths, messages, nested): + original = _USER_CONFIG + (f'\n[model_providers.Databricks]\n{nested} = "user scalar"\n') + paths.write_text(original, encoding="utf-8") + + codex.configure_vscode_extension(_gateway()) + + assert paths.read_text(encoding="utf-8") == original + assert len(messages["warning"]) == 1 + assert nested in messages["warning"][0] + assert not codex._vscode_record_path().exists() + + def test_vscode_record_is_durable_before_settings_write(self, paths, monkeypatch): + events: list[str] = [] + monkeypatch.setattr( + codex.config_io, + "atomic_write_json", + lambda _path, _record: events.append("record"), + ) + monkeypatch.setattr(codex, "write_toml_file", lambda _path, _doc: events.append("toml")) + + codex.configure_vscode_extension(_gateway()) + + assert events[:2] == ["record", "toml"] + + def test_dropped_admin_default_restores_the_users_model(self, paths): + paths.write_text('model = "gpt-5.5"\n' + _USER_CONFIG, encoding="utf-8") + codex.configure_vscode_extension(_gateway()) + assert self._load(paths)["model"] == "system.ai.gpt-5-6-sol" + codex.configure_vscode_extension(_gateway(model=None)) + assert self._load(paths)["model"] == "gpt-5.5" + + def test_revert_restores_the_users_file(self, paths): + paths.write_text('model = "gpt-5.5"\n' + _USER_CONFIG, encoding="utf-8") + codex.configure_vscode_extension(_gateway()) + + assert codex.revert_vscode_extension() == "restored" + doc = self._load(paths) + assert doc["model"] == "gpt-5.5" + assert "model_provider" not in doc + assert "model_providers" not in doc + assert doc["mcp_servers"]["webex"] == {"command": "ug", "args": ["mcp-proxy"]} + assert not codex._vscode_record_path().exists() + assert codex.revert_vscode_extension() == "unchanged" + + def test_revert_drops_a_gateway_model_picked_in_the_extension(self, paths): + codex.configure_vscode_extension(_gateway()) + paths.write_text( + paths.read_text(encoding="utf-8").replace( + "system.ai.gpt-5-6-sol", "system.ai.gpt-5-6-terra" + ), + encoding="utf-8", + ) + assert codex.revert_vscode_extension() == "restored" + # A model picked in the extension is user-owned after configure; revert only removes + # unchanged ug values. + assert self._load(paths)["model"] == "system.ai.gpt-5-6-terra" + + def test_revert_leaves_a_provider_the_user_switched_to(self, paths): + codex.configure_vscode_extension(_gateway()) + switched = paths.read_text(encoding="utf-8").replace( + 'model_provider = "Databricks"', 'model_provider = "azure"' + ) + paths.write_text(switched, encoding="utf-8") + assert codex.revert_vscode_extension() == "unchanged" + assert paths.read_text(encoding="utf-8") == switched + assert not codex._vscode_record_path().exists() + + def test_adopts_gateway_keys_added_by_hand(self, paths): + # Values that already match the gateway are not adopted as ug-owned state. Revert must not + # delete a user's hand-written gateway configuration. + codex.configure_vscode_extension(_gateway()) + by_hand = paths.read_text(encoding="utf-8") + codex._vscode_record_path().unlink() + codex.configure_vscode_extension(_gateway()) + assert paths.read_text(encoding="utf-8") == by_hand + assert codex.revert_vscode_extension() == "unchanged" + assert paths.read_text(encoding="utf-8") == by_hand + + def test_merges_preexisting_databricks_provider_fields(self, paths): + original = ( + 'model_provider = "Databricks"\n' + + _USER_CONFIG + + "\n[model_providers.Databricks]\n" + + 'base_url = "https://user.example/codex"\n' + + 'custom_option = "keep"\n' + ) + paths.write_text(original, encoding="utf-8") + + codex.configure_vscode_extension(_gateway()) + configured = self._load(paths) + provider = configured["model_providers"]["Databricks"] + assert provider["custom_option"] == "keep" + assert provider["base_url"] == _GATEWAY_URL + + assert codex.revert_vscode_extension() == "restored" + restored = self._load(paths) + assert restored["model_provider"] == "Databricks" + assert restored["model_providers"]["Databricks"] == { + "base_url": "https://user.example/codex", + "custom_option": "keep", + } + + def test_revert_preserves_provider_fields_edited_after_configure(self, paths): + codex.configure_vscode_extension(_gateway()) + paths.write_text( + paths.read_text(encoding="utf-8") + .replace(f'base_url = "{_GATEWAY_URL}"', 'base_url = "https://user.example/codex"') + .replace( + 'User-Agent = "ucode/1.0 codex/0.155.0"', + 'User-Agent = "ucode/1.0 codex/0.155.0"\nX-User-Header = "keep"', + ), + encoding="utf-8", + ) + + assert codex.revert_vscode_extension() == "restored" + restored = self._load(paths) + assert "model_provider" not in restored + assert restored["model_providers"]["Databricks"] == { + "base_url": "https://user.example/codex", + "http_headers": {"X-User-Header": "keep"}, + } diff --git a/tests/test_cli.py b/tests/test_cli.py index 9f69fb8fc..37b2a7255 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2792,6 +2792,25 @@ def test_reverts_the_vscode_extension_settings_before_clearing_state(self): assert order == ["vscode", "clear"] assert "VS Code Claude Code extension: restored" in _strip_ansi(result.output) + def test_reverts_the_codex_extension_settings_before_clearing_state(self): + order: list[str] = [] + + with ( + patch("ucode.cli.load_state", return_value=MINIMAL_STATE), + patch("ucode.cli.restore_file", return_value=False), + patch("ucode.cli.revert_mcp_configs", return_value={}), + patch( + "ucode.cli.codex_agent.revert_vscode_extension", + side_effect=lambda: order.append("codex vscode") or "restored", + ), + patch("ucode.cli.clear_state", side_effect=lambda: order.append("clear")), + ): + result = runner.invoke(app, ["revert"]) + + assert result.exit_code == 0, result.output + assert order == ["codex vscode", "clear"] + assert "VS Code Codex extension: restored" in _strip_ansi(result.output) + class TestVSCodeExtensionHook: @pytest.mark.parametrize( diff --git a/tests/test_vscode.py b/tests/test_vscode.py index 70027e781..62e21e53c 100644 --- a/tests/test_vscode.py +++ b/tests/test_vscode.py @@ -137,6 +137,41 @@ def test_missing_extensions_dir(self, tmp_path): assert vscode.has_claude_extension(install) is False +class TestCodexDetection: + CODEX = vscode.CODEX_EXTENSION_ID + + def _has(self, install): + return vscode.has_codex_extension(installs=lambda: [install]) + + def test_default_profile_lists_it(self, tmp_path): + install = _install(tmp_path, extension=None, listed=(self.CODEX,)) + assert self._has(install) is True + + def test_only_a_named_profile_has_it(self, tmp_path): + install = _install( + tmp_path, + extension=None, + listed=(vscode.CLAUDE_EXTENSION_ID,), + profiles=[{"name": "Dev", "location": "-16c12516"}], + ) + _extensions_json( + install.user_dir / "profiles" / "-16c12516" / "extensions.json", self.CODEX + ) + assert self._has(install) is True + + def test_claude_alone_is_not_codex(self, tmp_path): + install = _install(tmp_path, listed=(vscode.CLAUDE_EXTENSION_ID,)) + assert self._has(install) is False + + def test_folder_check_when_there_is_no_extensions_json(self, tmp_path): + install = _install(tmp_path, extension="openai.chatgpt-26.917.62051-win32-x64") + assert self._has(install) is True + + def test_no_vscode(self): + # The autouse fixture leaves no VS Code installs to find. + assert vscode.has_codex_extension() is False + + class TestProfiles: CLAUDE = vscode.CLAUDE_EXTENSION_ID