diff --git a/AGENTS.md b/AGENTS.md index 069543de4..8ada99b10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -123,6 +123,7 @@ Fields live in `~/.claude/ucode-settings.json` and the OS-managed settings file | `env.ENABLE_PROMPT_CACHING_1H` | Create/replace | Create/replace | Prompt-caching flag | | `env.ENABLE_TOOL_SEARCH` | Create/replace | Create/replace | Tool-search flag | | `env.CLAUDE_CODE_USE_GATEWAY` | Create/replace | Create/replace | Gateway-routing flag | +| `env.ANTHROPIC_AUTH_TOKEN`, `env.ANTHROPIC_API_KEY` | Create/replace | Create/replace | Written as empty strings so a value in the user's shell can't outrank `ug`'s credential; Claude Code applies settings `env` over the shell, so commands it runs see them empty. In `ucode-settings.json` and in `ug`'s own drop-in `managed-settings.d/50-ug-credentials.json` beside the OS-managed file, never in `managed-settings.json` itself, so a program that templates that file never drops them and `ug` never re-prompts for them | | `env.ANTHROPIC_MODEL` | Ignore | Create/replace | Launch model, from the config's `default_model` or the budget recommendation when spend tiers are set; written at launch, not by `ug configure` | | `env.ANTHROPIC_DEFAULT_*_MODEL` | Create/replace | Create/replace | Per-family default (opus, sonnet, haiku, fable); the config's family default wins under managed config. Without a managed config, left as the OS-managed file has them (model discovery skipped) when that file has a `modelPicker` with family defaults | | Model picker | Ignore | Create/replace | `availableModels`, `enforceAvailableModels`, `modelPicker`; only for a managed static model list | diff --git a/src/ucode/agents/claude.py b/src/ucode/agents/claude.py index e288a6867..47f100323 100644 --- a/src/ucode/agents/claude.py +++ b/src/ucode/agents/claude.py @@ -53,6 +53,7 @@ from ucode.launcher import exec_or_spawn from ucode.managed_config import refresh_managed_config from ucode.managed_files import ( + CLAUDE_CREDENTIALS_DROPIN, OS, ManagedFileSnapshots, ManagedFileWriteUnavailable, @@ -68,6 +69,7 @@ managed_file_status, managed_files_supported, managed_settings_skipped, + managed_write_session, managed_writes_allowed, mark_managed_file_verified, plan_settings_passthrough, @@ -75,6 +77,7 @@ reconcile_managed_file, record_settings_passthrough, record_ug_picker, + remove_empty_dropin_folder, revert_managed_file, warn_managed_write_skipped, warn_skipped_settings_passthrough, @@ -102,7 +105,7 @@ ) from ucode.state import MANAGED_OVERLAY_KEY, is_tool_managed, mark_tool_managed, save_state from ucode.telemetry import agent_version, ug_version -from ucode.ui import print_note, print_success, print_warning +from ucode.ui import print_note, print_success, print_warning, print_warning_err from .args import LaunchOptions, has_explicit_model_arg from .request_tags import request_tags_header_value @@ -110,6 +113,11 @@ GATEWAY_MODEL_DISCOVERY_ENV_VAR = "ENABLE_CLAUDE_CODE_GATEWAY_MODEL_DISCOVERY" # If set, Claude Code launches in headless mode instead of the interactive login flow. CLAUDE_CODE_OAUTH_TOKEN_ENV_VAR = "CLAUDE_CODE_OAUTH_TOKEN" +# Blank inherited credentials so Claude uses ug's configured authentication. +SHADOWING_CREDENTIAL_ENV_VARS = ("ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY") +_SHADOWING_CREDENTIAL_PATHS = [["env", name] for name in SHADOWING_CREDENTIAL_ENV_VARS] +# Backup-manifest key for ug's managed drop-in, kept apart from managed-settings.json's "claude". +CREDENTIALS_DROPIN_MANIFEST_KEY = "claude-credentials" CLAUDE_CONFIG_DIR = Path.home() / ".claude" CLAUDE_SETTINGS_PATH = CLAUDE_CONFIG_DIR / "ucode-settings.json" CLAUDE_MCP_CONFIG_PATH = Path.home() / ".claude.json" @@ -295,6 +303,12 @@ def _managed_settings_path() -> Path | None: return None +def _credentials_dropin_path() -> Path | None: + """ug's managed drop-in, beside managed-settings.json, that blanks inherited credentials.""" + path = _managed_settings_path() + return None if path is None else path.parent / CLAUDE_CREDENTIALS_DROPIN + + def _parse_managed_settings(text: str) -> dict: try: settings = json.loads(text) @@ -394,12 +408,22 @@ def managed_settings_status(state: dict) -> tuple[Path | None, str, str]: def revert_managed_settings() -> str: - return revert_managed_file( + result = revert_managed_file( "claude", display="Claude Code", parser=_parse_managed_settings, dumper=_dump_managed_settings, ) + dropin_result = revert_managed_file( + CREDENTIALS_DROPIN_MANIFEST_KEY, + display="Claude Code", + parser=_parse_managed_settings, + dumper=_dump_managed_settings, + ) + dropin = _credentials_dropin_path() + if dropin_result == "removed" and dropin is not None: + remove_empty_dropin_folder(dropin) + return result def _managed_relayed_conflicts(path: Path) -> list[str]: @@ -534,6 +558,8 @@ def render_overlay( "ENABLE_PROMPT_CACHING_1H": "1", "ENABLE_TOOL_SEARCH": "true", "CLAUDE_CODE_USE_GATEWAY": "1", + # Blank, so a credential in the user's shell can't outrank ug's on this or a bare `claude`. + **dict.fromkeys(SHADOWING_CREDENTIAL_ENV_VARS, ""), } # Intentionally NOT setting ANTHROPIC_MODEL by default. Setting it produces a # duplicate catalog row in Claude Code's /model picker (e.g. "Opus 4.8 (1M @@ -1602,9 +1628,9 @@ def _compose( ) previous_leaves = (managed_snapshots.settings_passthrough if managed_snapshots else None) or [] _warn_if_settings_disable_smart_routing(passthrough, previous_leaves) - _reconcile_managed_settings( - state, - lambda base: _allow_ug_mcp_servers( + + def compose_managed(base: dict) -> dict: + return _allow_ug_mcp_servers( apply_settings_passthrough( _compose( base, @@ -1627,8 +1653,17 @@ def _compose( base.get(MANAGED_MCP_SETTINGS_KEY), web_search=bool(web_search_model and not external_search), ), - ), - [*managed_file_keys, *passthrough.paths], + ) + + _reconcile_managed_settings( + state, + _keep_managed_file_credentials(compose_managed), + # ug's drop-in owns the blank credentials, so managed-settings.json doesn't. + [ + path + for path in [*managed_file_keys, *passthrough.paths] + if path not in _SHADOWING_CREDENTIAL_PATHS + ], relayed, [key for key in CLAUDE_MANAGED_PICKER_KEYS if key in overlay], withdrawn_denies=withdrawn_list_items( @@ -1928,27 +1963,82 @@ def _reconcile_managed_settings( warn_managed_write_skipped(path, managed_before, desired_settings) mark_managed_file_verified(state, "claude", path, scope="local-compatible") return + # One privileged session for both files, so the drop-in never costs a second sudo prompt. + with managed_write_session(): + try: + reconcile_managed_file( + path, + _dump_managed_settings(desired_settings), + tool="claude", + display="Claude Code", + owned_paths=owned_paths, + parser=_parse_managed_settings, + ) + except ManagedFileWriteUnavailable: + if conflicts: + raise + print_warning( + f"Claude Code OS-managed settings could not be updated at {path}; continuing " + f"with local settings at {CLAUDE_SETTINGS_PATH}." + ) + mark_managed_file_verified(state, "claude", path, scope="local-compatible") + return + _reconcile_credentials_dropin() + mark_managed_file_verified(state, "claude", path) + record_ug_picker("claude", {key: desired_settings[key] for key in picker_keys}) + record_settings_passthrough("claude", passthrough_leaves or []) + + +def _reconcile_credentials_dropin() -> None: + """Blank inherited Anthropic credentials for every Claude Code session on the machine. + + The blanks live in ug's own drop-in rather than managed-settings.json: Claude Code merges + `managed-settings.d/*.json` after that file, `env` key by key, so another program that templates + managed-settings.json neither removes them nor makes ug rewrite them on every launch. A failure + only leaves a bare `claude` uncovered; `ug claude` gets the same blanks from ucode-settings.json. + Runs that can't prompt skip it quietly rather than warn on every non-interactive launch. + """ + path = _credentials_dropin_path() + if path is None or not managed_writes_allowed(): + return try: reconcile_managed_file( path, - _dump_managed_settings(desired_settings), - tool="claude", + _dump_managed_settings({"env": dict.fromkeys(SHADOWING_CREDENTIAL_ENV_VARS, "")}), + tool=CREDENTIALS_DROPIN_MANIFEST_KEY, display="Claude Code", - owned_paths=owned_paths, + owned_paths=_SHADOWING_CREDENTIAL_PATHS, parser=_parse_managed_settings, ) - except ManagedFileWriteUnavailable: - if conflicts: - raise - print_warning( - f"Claude Code OS-managed settings could not be updated at {path}; continuing with " - f"local settings at {CLAUDE_SETTINGS_PATH}." - ) - mark_managed_file_verified(state, "claude", path, scope="local-compatible") - return - mark_managed_file_verified(state, "claude", path) - record_ug_picker("claude", {key: desired_settings[key] for key in picker_keys}) - record_settings_passthrough("claude", passthrough_leaves or []) + except RuntimeError as exc: + print_warning(f"Could not update Claude Code's credential drop-in at {path}: {exc}") + + +def _keep_managed_file_credentials(compose: Callable[[dict], dict]) -> Callable[[dict], dict]: + """Compose managed-settings.json without ug's blank credentials, which its drop-in carries. + + Values the file already holds for those variables are kept as they are. + """ + + def composed(base: dict) -> dict: + base_env = base.get("env") + # Read before composing: the compose step merges ug's overlay into ``base`` in place. + kept = { + name: base_env[name] + for name in SHADOWING_CREDENTIAL_ENV_VARS + if isinstance(base_env, dict) and name in base_env + } + settings = compose(base) + env = settings.get("env") + if isinstance(env, dict): + for name in SHADOWING_CREDENTIAL_ENV_VARS: + if name in kept: + env[name] = kept[name] + else: + env.pop(name, None) + return settings + + return composed def _preserve_permission_denies( @@ -2301,12 +2391,34 @@ def token_provider(force_refresh: bool) -> str: raise SystemExit(returncode) +def shadowing_credentials_message() -> str: + """Why a shell ANTHROPIC_AUTH_TOKEN / ANTHROPIC_API_KEY doesn't reach ug's Claude sessions.""" + return ( + f"{' and '.join(SHADOWING_CREDENTIAL_ENV_VARS)} from your environment are ignored by " + "Claude Code sessions ug configures, which use ug's credentials. Commands Claude runs see " + "them empty. To authenticate with a PAT in scripts, set DATABRICKS_BEARER instead." + ) + + +def _warn_shadowing_credentials() -> None: + """Tell the user that ug's settings override inherited Anthropic credentials. + + ug owns Claude Code's authentication on every launch path (the gateway `apiKeyHelper`, or the + subscription login behind the relayed proxy). Its settings blank these variables; see + ``SHADOWING_CREDENTIAL_ENV_VARS``. The user's environment itself is left unchanged. + """ + if any(os.environ.get(name, "").strip() for name in SHADOWING_CREDENTIAL_ENV_VARS): + # stderr: headless launches keep stdout machine-readable. + print_warning_err(shadowing_credentials_message()) + + def launch( state: dict, tool_args: list[str], *, options: LaunchOptions, ) -> None: + _warn_shadowing_credentials() tool_args = _external_web_search_args(state, tool_args) binary = SPEC["binary"] workspace = state.get("workspace") diff --git a/src/ucode/doctor.py b/src/ucode/doctor.py index 7867cf7f8..7bb1a6bc6 100644 --- a/src/ucode/doctor.py +++ b/src/ucode/doctor.py @@ -21,6 +21,7 @@ tool_version_error, update_tool_binary, ) +from ucode.agents.claude import SHADOWING_CREDENTIAL_ENV_VARS, shadowing_credentials_message from ucode.databricks import ( MIN_DATABRICKS_CLI_VERSION, check_databricks_auth, @@ -48,11 +49,6 @@ status_badge, ) -# Env vars that shadow the credential ucode configures for Claude Code. Claude -# warns when its own token and one of these are both set, so we surface them. -_CLAUDE_TOKEN_ENV_VARS = ("ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY") - - # status -> (glyph, status_badge kind). "info" is a healthy line that still # carries an optional suggestion (e.g. installing a missing dependency). _BADGES = { @@ -251,22 +247,16 @@ def _login() -> bool: def _check_anthropic_env_collision() -> Check | None: - """Warn when a Claude token env var is set that collides with ucode's own. + """Warn when a Claude token env var is set, since ug's Claude settings blank it. - ucode authenticates Claude Code through the gateway; a stray - `ANTHROPIC_AUTH_TOKEN`/`ANTHROPIC_API_KEY` in the environment shadows that - and makes Claude complain. We can't unset a parent shell's env, so this is - advisory (no fix). Returns None when nothing collides. + Claude Code ranks `ANTHROPIC_AUTH_TOKEN`/`ANTHROPIC_API_KEY` above ug's + `apiKeyHelper`, so ug's settings set both to empty strings; a shell value is + therefore ignored, which matters to scripts that relied on it. Advisory only + (no fix). Returns None when neither is set. """ - set_vars = [name for name in _CLAUDE_TOKEN_ENV_VARS if os.environ.get(name, "").strip()] - if not set_vars: + if not any(os.environ.get(name, "").strip() for name in SHADOWING_CREDENTIAL_ENV_VARS): return None - joined = ", ".join(set_vars) - return Check( - "Claude auth env", - "warn", - f"{joined} is set and can collide with ucode's Claude auth; unset it in your shell", - ) + return Check("Claude auth env", "warn", shadowing_credentials_message()) def _check_ug() -> Check: diff --git a/src/ucode/managed_files.py b/src/ucode/managed_files.py index d7f135c40..88f04b98b 100644 --- a/src/ucode/managed_files.py +++ b/src/ucode/managed_files.py @@ -67,16 +67,24 @@ class OS(Enum): MANAGED_FILES_SUPPORTED_OSES = (OS.LINUX, OS.MACOS, OS.WINDOWS) +# ug's own Claude Code drop-in. Claude Code merges `managed-settings.d/*.json` after +# managed-settings.json, so another program that templates that file leaves this one alone. +CLAUDE_CREDENTIALS_DROPIN = Path("managed-settings.d") / "50-ug-credentials.json" + _SUDO_REPLACE_TARGETS = { OS.LINUX: frozenset( { Path("/etc/claude-code/managed-settings.json"), + Path("/etc/claude-code/managed-settings.d/50-ug-credentials.json"), Path("/etc/codex/managed_config.toml"), } ), OS.MACOS: frozenset( { Path("/Library/Application Support/ClaudeCode/managed-settings.json"), + Path( + "/Library/Application Support/ClaudeCode/managed-settings.d/50-ug-credentials.json" + ), Path("/etc/codex/managed_config.toml"), } ), @@ -138,6 +146,11 @@ def windows_claude_managed_settings_path() -> Path: return system_cross_os.windows_program_files_dir() / "ClaudeCode" / "managed-settings.json" +def windows_claude_credentials_dropin_path() -> Path: + """ug's Claude Code drop-in on Windows, beside the managed settings file.""" + return windows_claude_managed_settings_path().parent / CLAUDE_CREDENTIALS_DROPIN + + CODEX_REQUIREMENTS_FILE = "requirements.toml" @@ -1347,8 +1360,41 @@ def _privileged_remove(path: Path) -> None: path.unlink(missing_ok=True) +def remove_empty_dropin_folder(dropin: Path) -> None: + """After ``ug revert`` removes ug's drop-in, remove its folder too if nothing else is in it. + + Leaves the managed directory as ug found it. A folder holding other drop-ins is kept, and a + failed removal only leaves an empty folder behind. + """ + folder = dropin.parent + if folder.name != CLAUDE_CREDENTIALS_DROPIN.parent.name or folder.is_symlink(): + return + try: + if not folder.is_dir() or any(folder.iterdir()): + return + except OSError: + return + if current_os() is OS.WINDOWS: + with suppress(OSError): + folder.rmdir() + return + _sudo_rmdir(folder) + + +def _sudo_rmdir(folder: Path) -> None: + # `rmdir` refuses a non-empty folder, so a drop-in added meanwhile is never lost. + subprocess_cross_os.run( + _sudo_command("rmdir", str(folder)), capture_output=True, text=True, check=False + ) + + def _validate_windows_target(path: Path) -> None: - if path not in (windows_claude_managed_settings_path(), windows_codex_managed_settings_path()): + allowed = ( + windows_claude_managed_settings_path(), + windows_claude_credentials_dropin_path(), + windows_codex_managed_settings_path(), + ) + if path not in allowed: raise RuntimeError(f"Refusing unexpected managed-settings target: {path}") if path.is_symlink(): raise RuntimeError(f"Refusing to replace symlinked managed settings: {path}") diff --git a/src/ucode/managed_replace.sh b/src/ucode/managed_replace.sh index 64f5e3343..6922dd770 100644 --- a/src/ucode/managed_replace.sh +++ b/src/ucode/managed_replace.sh @@ -29,8 +29,10 @@ esac target_is_allowed() { case "$platform:$1" in "linux:/etc/claude-code/managed-settings.json"|\ + "linux:/etc/claude-code/managed-settings.d/50-ug-credentials.json"|\ "linux:/etc/codex/managed_config.toml"|\ "macos:/Library/Application Support/ClaudeCode/managed-settings.json"|\ + "macos:/Library/Application Support/ClaudeCode/managed-settings.d/50-ug-credentials.json"|\ "macos:/etc/codex/managed_config.toml") return 0 ;; *) return 1 ;; esac diff --git a/tests/README.md b/tests/README.md index cee5d4140..a732b68b6 100644 --- a/tests/README.md +++ b/tests/README.md @@ -180,6 +180,14 @@ These are component checks, not live Windows coverage for every agent. Agent configuration tests also verify `ug` auth/MCP helper commands, including quoted executable paths and replacement of legacy `ucode` routing/web-search helpers. +`test_agent_claude.py` also checks that ug's Claude settings blank `ANTHROPIC_AUTH_TOKEN` and +`ANTHROPIC_API_KEY` as ug-owned keys in the private file, for default and relayed setups, and in +ug's `managed-settings.d/50-ug-credentials.json` drop-in rather than `managed-settings.json`. The +drop-in shares the managed file's password prompt, is skipped when privileged writes are +unavailable, is not rewritten after another program rewrites `managed-settings.json`, and is +removed by revert. Launches leave the user's environment unchanged, warn only on stderr, and stay +silent for empty values. These component checks replace the exec, relay proxy and privileged +write; `test_managed_files.py` covers the drop-in in both sudo allowlists and its Windows folder. `test_desktop_auth.py`, `test_desktop_auth_cli.py`, and `test_file_lock_cross_os.py` cover Desktop token-only stdout, explicit workspace/profile arguments, interactive-only @@ -500,7 +508,7 @@ integration utilities; only CUJ-specific evidence correlation stays in a test fi | `test_case_12_*` | Launch configured and fresh Codex with a provider | The provider supplies exactly its model catalog | | `test_case_13_*` | Launch configured and fresh Claude with a model location and no managed config | The explicit parent's catalog replaces built-in picker rows and appears in the real picker; Default resolves to the model in the scoped fixture catalog | | `test_case_14_*` | Launch configured and fresh Codex with a model location | The app-server list exactly matches the independent API-compatible parent catalog, includes the dedicated Codex service, and contains no out-of-schema models | -| `test_ug_claude_headless_prompt_argument`, `test_ug_claude_headless_prompt_stdin`, `test_ug_claude_headless_prompt_after_separator` | Run Claude from a script using each prompt form | Structured final answer contains the file value; exit zero; no routing | +| `test_ug_claude_headless_prompt_argument`, `test_ug_claude_headless_prompt_stdin`, `test_ug_claude_headless_prompt_after_separator` | Run Claude from a script using each prompt form; the argument form runs while the shell exports `ANTHROPIC_AUTH_TOKEN` and `ANTHROPIC_API_KEY` from another LLM gateway | Structured final answer contains the file value; exit zero; no routing; the argument form's stderr reports both shell variables as ignored | | `test_ug_codex_headless_prompt_argument`, `test_ug_codex_headless_prompt_stdin`, `test_ug_codex_headless_prompt_after_separator` | Run Codex from a script using each prompt form | Completed turn and final answer contain the file value; exit zero; no routing | | `test_ug_copilot_headless_completes_file_task`, `test_ug_copilot_headless_gpt_responses_task` | Run Copilot CLI from a script (`-p ... --output-format json`) on a Claude model (Anthropic Messages) and a GPT model (Responses) | Completed turn whose assistant message contains the file value and was sent as the requested model; the session started on the matching registry provider; no file under the session home holds the Databricks credential; no session registry left (non-blocking CI lane) | | `test_ug_copilot_one_setup_runs_claude_then_gpt_by_provider_ref` | One configure, then headless tasks on a Claude model older than its family's newest (only the full catalog registers it) and a GPT model chosen as `dbx-responses/` | Each answer contains its file value and was sent as the requested model; sessions started on `dbx-anthropic` and `dbx-responses` (non-blocking CI lane) | diff --git a/tests/integration/README.md b/tests/integration/README.md index 980ff57a7..14d1297d2 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -481,7 +481,7 @@ test_ug_configure_claude.py # Databricks Hosted and Anthropic MPS test_ug_configure_codex.py # Databricks Hosted and OpenAI MPS test_ug_claude_custom_oauth.py # CLI custom-OAuth launch, profile, and managed helper test_ug_codex_custom_oauth.py # CLI custom-OAuth launch and profile -test_ug_claude_headless.py # script prompts, models, caller settings +test_ug_claude_headless.py # script prompts, models, caller settings, shell credentials test_ug_claude_relayed.py # relayed session: subscription + Databricks-hosted models test_ug_claude_tracing.py # Claude OTLP export reaches the configured trace table test_ug_codex_headless.py # script prompts and model arguments diff --git a/tests/integration/test_ug_claude_headless.py b/tests/integration/test_ug_claude_headless.py index 66ea2d5c7..f5bd8deed 100644 --- a/tests/integration/test_ug_claude_headless.py +++ b/tests/integration/test_ug_claude_headless.py @@ -11,10 +11,12 @@ @pytest.mark.smoke def test_ug_claude_headless_prompt_argument(live_session, workspace): - """Scenario: configure claude and submit a headless prompt via argument. + """Scenario: configure claude, then submit a headless prompt via argument from a shell that + still exports another LLM gateway's ANTHROPIC_AUTH_TOKEN and ANTHROPIC_API_KEY. - Expected: the real agent reads the fixture and returns its unknown value in - its structured completed answer, with exit code zero and JSON-only stdout. + Expected: the real agent authenticates through ug's credentials, reads the fixture and returns + its unknown value in its structured completed answer, with exit code zero and JSON-only stdout; + stderr reports that both shell variables are ignored. """ session = live_session task = FileTask(session) @@ -28,6 +30,9 @@ def test_ug_claude_headless_prompt_argument(live_session, workspace): "--skip-upgrade", "--disable-databricks-ai-tools", ) + # Scenario input: credentials left in the user's shell by another LLM gateway setup. + session.env["ANTHROPIC_AUTH_TOKEN"] = "sk-other-gateway-placeholder" + session.env["ANTHROPIC_API_KEY"] = "sk-other-gateway-placeholder" result = session.run( "claude", @@ -42,6 +47,7 @@ def test_ug_claude_headless_prompt_argument(live_session, workspace): ) assert json.loads(result.stdout)["type"] == "result" task.assert_headless_answer("claude", result) + assert "ANTHROPIC_AUTH_TOKEN and ANTHROPIC_API_KEY" in " ".join(result.stderr.split()) session.assert_not_routed() diff --git a/tests/test_agent_claude.py b/tests/test_agent_claude.py index dae57f18d..c3ef30540 100644 --- a/tests/test_agent_claude.py +++ b/tests/test_agent_claude.py @@ -20,6 +20,14 @@ from ucode.state import MANAGED_OVERLAY_KEY WS = "https://example.databricks.com" + + +def _write_creating_parent(target: Path, text: str) -> None: + """Stand-in for the privileged write, which creates a missing parent such as managed-settings.d.""" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(text, encoding="utf-8") + + # A connection MCP proxy argv, used by the Claude MCP-registration helper tests. # The leading element is the resolved `ug` binary path, so tests assert the tail. GH_URL = f"{WS}/api/2.0/mcp/external/github" @@ -292,6 +300,17 @@ def test_enables_use_gateway(self): overlay, _ = claude.render_overlay(WS, "s4") assert overlay["env"]["CLAUDE_CODE_USE_GATEWAY"] == "1" + @pytest.mark.parametrize("relayed", [False, True]) + def test_blanks_shadowing_credentials_as_owned_keys(self, relayed): + # Claude Code applies settings env over the shell, so these empty values keep a shell + # ANTHROPIC_AUTH_TOKEN / ANTHROPIC_API_KEY from outranking ug's credential. + overlay, keys = claude.render_overlay( + WS, "s4", relayed=relayed, relayed_base_url="http://localhost:8000" if relayed else None + ) + for name in claude.SHADOWING_CREDENTIAL_ENV_VARS: + assert overlay["env"][name] == "" + assert ["env", name] in keys + @pytest.mark.parametrize("env_value", [None, "", "0", "true", "yes"]) def test_gateway_model_discovery_disabled_unless_opted_in(self, monkeypatch, env_value): if env_value is not None: @@ -985,6 +1004,7 @@ def test_strips_stale_otel_tracing_when_disabled(self, monkeypatch): FAKE_MANAGED_PATH = Path("/tmp/ucode-test/managed-settings.json") +FAKE_DROPIN_PATH = FAKE_MANAGED_PATH.parent / "managed-settings.d" / "50-ug-credentials.json" class TestWriteToolConfigManagedSettings: @@ -1082,9 +1102,9 @@ def test_writes_managed_file_by_default(self, monkeypatch): self._patch(monkeypatch, private_writes, managed_writes) state = {"workspace": WS, "codex_models": []} claude.write_tool_config(state, "databricks-claude-sonnet-4") - # Private file still written; managed file written too. + # Private file still written; managed file and ug's credential drop-in written too. assert str(claude.CLAUDE_SETTINGS_PATH) in [p for p, _ in private_writes] - assert [p for p, _ in managed_writes] == [str(FAKE_MANAGED_PATH)] + assert [p for p, _ in managed_writes] == [str(FAKE_MANAGED_PATH), str(FAKE_DROPIN_PATH)] assert "modelPicker" not in private_writes[0][1] assert "modelPicker" not in json.loads(managed_writes[0][1]) @@ -1397,11 +1417,7 @@ def test_managed_file_wholesale_overwrite_survives_real_reconcile_round_trip( monkeypatch.setattr( managed_files, "MANAGED_BACKUP_MANIFEST_PATH", backup_dir / "manifest.json" ) - monkeypatch.setattr( - managed_files, - "_sudo_replace", - lambda target, text: target.write_text(text, encoding="utf-8"), - ) + monkeypatch.setattr(managed_files, "_sudo_replace", _write_creating_parent) monkeypatch.setattr(claude, "_managed_settings_path", lambda: managed_path) monkeypatch.setattr(claude, "managed_writes_allowed", lambda: True) monkeypatch.setattr(managed_files.sys.stdin, "isatty", lambda: True) @@ -1459,12 +1475,21 @@ def _sudo_counting_env(self, tmp_path, monkeypatch): managed_files, "MANAGED_BACKUP_MANIFEST_PATH", backup_dir / "manifest.json" ) + # Counts managed-settings.json writes only; ug's credential drop-in has its own tests. def _write(target, text): - sudo_writes.append(text) - target.write_text(text, encoding="utf-8") + if target == managed_path: + sudo_writes.append(text) + _write_creating_parent(target, text) + + def _remove(target): + if target == managed_path: + sudo_writes.append("remove") + else: + target.unlink(missing_ok=True) monkeypatch.setattr(managed_files, "_sudo_replace", _write) - monkeypatch.setattr(managed_files, "_sudo_remove", lambda *a: sudo_writes.append("remove")) + monkeypatch.setattr(managed_files, "_sudo_remove", _remove) + monkeypatch.setattr(managed_files, "_sudo_rmdir", lambda folder: folder.rmdir()) monkeypatch.setattr(claude, "_managed_settings_path", lambda: managed_path) monkeypatch.setattr(claude, "managed_writes_allowed", lambda: True) monkeypatch.setattr(managed_files.sys.stdin, "isatty", lambda: True) @@ -1493,6 +1518,90 @@ def test_reapply_unchanged_config_invokes_no_sudo(self, tmp_path, monkeypatch): assert len(sudo_writes) == 1 # no further privileged writes assert managed_path.read_bytes() == first_bytes # exact bytes preserved + def test_credential_dropin_is_written_once_and_never_churns(self, tmp_path, monkeypatch): + # Another program may rewrite managed-settings.json from its own template on every launch. + # It never sees ug's blanks there, so neither file needs rewriting on later launches. + managed_path, sudo_writes = self._sudo_counting_env(tmp_path, monkeypatch) + dropin = managed_path.parent / managed_files.CLAUDE_CREDENTIALS_DROPIN + dropin_writes: list[Path] = [] + replace = managed_files._sudo_replace + + def counting_replace(target, text): + if target == dropin: + dropin_writes.append(target) + replace(target, text) + + monkeypatch.setattr(managed_files, "_sudo_replace", counting_replace) + state = {"workspace": WS, "codex_models": []} + claude.write_tool_config(state, "databricks-claude-sonnet-4") + + assert json.loads(dropin.read_text()) == { + "env": dict.fromkeys(claude.SHADOWING_CREDENTIAL_ENV_VARS, "") + } + template = json.loads(managed_path.read_text()) + assert not set(claude.SHADOWING_CREDENTIAL_ENV_VARS) & set(template["env"]) + # The other program writes its template back, as it does on every launch. + managed_path.write_text(json.dumps(template, indent=2) + "\n", encoding="utf-8") + claude.write_tool_config(state, "databricks-claude-sonnet-4") + + assert len(sudo_writes) == 1 + assert dropin_writes == [dropin] + + def test_credential_dropin_shares_the_managed_file_password_prompt(self, tmp_path, monkeypatch): + managed_path, _ = self._sudo_counting_env(tmp_path, monkeypatch) + notes: list[str] = [] + monkeypatch.setattr(managed_files, "print_note", notes.append) + replace = managed_files._sudo_replace + + class _Worker: + usable = True + + def close(self) -> None: + pass + + def session_replace(target, text): + # A real write inside a session starts the privileged worker that later writes reuse. + if managed_files._managed_write_session_depth: + managed_files._managed_write_worker = ( + managed_files._managed_write_worker or _Worker() + ) + replace(target, text) + + monkeypatch.setattr(managed_files, "_sudo_replace", session_replace) + claude.write_tool_config({"workspace": WS, "codex_models": []}, None) + + assert (managed_path.parent / managed_files.CLAUDE_CREDENTIALS_DROPIN).is_file() + assert [note for note in notes if note.startswith("Enter password")] == [ + "Enter password once to configure machine-wide coding agent settings." + ] + + def test_revert_removes_the_credential_dropin(self, tmp_path, monkeypatch): + managed_path, _ = self._sudo_counting_env(tmp_path, monkeypatch) + managed_path.write_text(json.dumps({"env": {"ISAAC_ONLY": "keep"}}), encoding="utf-8") + dropin = managed_path.parent / managed_files.CLAUDE_CREDENTIALS_DROPIN + claude.write_tool_config({"workspace": WS, "codex_models": []}, None) + assert dropin.is_file() + + claude.revert_managed_settings() + + assert not dropin.exists() + # The folder ug created goes too, so the managed directory is left as it was. + assert not dropin.parent.exists() + assert json.loads(managed_path.read_text()) == {"env": {"ISAAC_ONLY": "keep"}} + + def test_revert_keeps_a_dropin_folder_holding_other_files(self, tmp_path, monkeypatch): + managed_path, _ = self._sudo_counting_env(tmp_path, monkeypatch) + managed_path.write_text(json.dumps({"env": {"ISAAC_ONLY": "keep"}}), encoding="utf-8") + dropin = managed_path.parent / managed_files.CLAUDE_CREDENTIALS_DROPIN + claude.write_tool_config({"workspace": WS, "codex_models": []}, None) + admin_dropin = dropin.parent / "10-admin.json" + admin_dropin.write_text('{"env": {"ADMIN": "1"}}', encoding="utf-8") + + claude.revert_managed_settings() + + assert not dropin.exists() + assert admin_dropin.read_text(encoding="utf-8") == '{"env": {"ADMIN": "1"}}' + def test_admin_unrelated_edit_invokes_no_sudo(self, tmp_path, monkeypatch): # An admin's unrelated edit (a new policy key, keys reordered) is preserved and does not # trigger a ug privileged write, because the composed document is semantically unchanged. @@ -2673,6 +2782,40 @@ def test_static_models_not_written_when_absent(self, monkeypatch): assert "availableModels" not in managed_content assert "modelPicker" not in managed_content + def test_blank_credentials_go_to_private_file_and_dropin_not_managed_file(self, monkeypatch): + # A program that templates managed-settings.json would drop blanks written there and make + # ug rewrite them (and prompt for sudo) on every launch, so they live in ug's own drop-in. + # An existing value in managed-settings.json is left as it is; the later drop-in wins. + private_writes: list = [] + managed_writes: list = [] + existing = {str(FAKE_MANAGED_PATH): {"env": {"ANTHROPIC_API_KEY": "it-authored"}}} + self._patch(monkeypatch, private_writes, managed_writes, existing_by_path=existing) + + state = claude.write_tool_config( + {"workspace": WS, "codex_models": []}, "databricks-claude-sonnet-4" + ) + + private = next(p for path, p in private_writes if path == str(claude.CLAUDE_SETTINGS_PATH)) + written = dict(managed_writes) + managed = json.loads(written[str(FAKE_MANAGED_PATH)]) + dropin = json.loads(written[str(FAKE_DROPIN_PATH)]) + assert managed["env"]["ANTHROPIC_API_KEY"] == "it-authored" + assert "ANTHROPIC_AUTH_TOKEN" not in managed["env"] + assert dropin == {"env": dict.fromkeys(claude.SHADOWING_CREDENTIAL_ENV_VARS, "")} + for name in claude.SHADOWING_CREDENTIAL_ENV_VARS: + assert private["env"][name] == "" + assert ["env", name] in state["managed_configs"]["claude"]["keys"] + + def test_dropin_is_skipped_when_privileged_writes_are_unavailable(self, monkeypatch): + private_writes: list = [] + managed_writes: list = [] + self._patch(monkeypatch, private_writes, managed_writes) + monkeypatch.setattr(claude, "managed_writes_allowed", lambda: False) + + claude.write_tool_config({"workspace": WS, "codex_models": []}, None) + + assert str(FAKE_DROPIN_PATH) not in [p for p, _ in managed_writes] + def test_settings_passthrough_lands_in_managed_file_only(self, monkeypatch): private_writes: list = [] managed_writes: list = [] @@ -3238,6 +3381,37 @@ def test_default_launch_keeps_existing_auth_path(self, monkeypatch): assert "ANTHROPIC_DEFAULT_MODEL" not in os.environ assert calls == [["claude", "--settings", str(claude.CLAUDE_SETTINGS_PATH), "--debug"]] + def test_launch_warns_on_shadowing_credentials_and_keeps_env(self, monkeypatch, capsys): + # ug's settings blank these for Claude Code; the user's own environment stays as it was. + monkeypatch.delenv(v2.ENABLE_SMART_ROUTING_ENV_VAR, raising=False) + monkeypatch.delenv(claude.GATEWAY_MODEL_DISCOVERY_ENV_VAR, raising=False) + monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "sk-other-gateway") + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-other-gateway") + # launch() exports the minted token; register it so teardown removes it again. + monkeypatch.delenv("OAUTH_TOKEN", raising=False) + monkeypatch.setattr(claude, "get_databricks_token", lambda *_args: "token") + monkeypatch.setattr(claude, "exec_or_spawn", lambda _argv: None) + + claude.launch({"workspace": WS, "profile": "test"}, ["-p", "hi"], options=LaunchOptions()) + + assert os.environ["ANTHROPIC_AUTH_TOKEN"] == "sk-other-gateway" + assert os.environ["ANTHROPIC_API_KEY"] == "sk-other-gateway" + captured = capsys.readouterr() + err = " ".join(captured.err.split()) + assert "ANTHROPIC_AUTH_TOKEN and ANTHROPIC_API_KEY" in err + # Points scripted PAT users at ug's own bearer override. + assert "set DATABRICKS_BEARER instead" in err + assert "ANTHROPIC" not in captured.out + + def test_launch_does_not_warn_for_empty_credentials(self, monkeypatch, capsys): + monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", " ") + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.setattr(claude, "_launch_relayed", lambda *_args: None) + + claude.launch({"workspace": WS, "claude_relayed": True}, [], options=LaunchOptions()) + + assert "ANTHROPIC" not in capsys.readouterr().err + def test_windows_launch_preserves_prompt_as_literal_argv(self, monkeypatch, tmp_path): native_binary = tmp_path / "Claude Code" / "claude.exe" prompt = 'keep "quotes" & pipes | and %PATH% literal' diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 23388c6aa..b59d2d04b 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -248,22 +248,25 @@ def test_login_fix_reports_failure_when_login_raises(self): class TestAnthropicEnvCollision: def test_none_when_unset(self, monkeypatch): - for var in doctor_mod._CLAUDE_TOKEN_ENV_VARS: + for var in doctor_mod.SHADOWING_CREDENTIAL_ENV_VARS: monkeypatch.delenv(var, raising=False) assert _check_anthropic_env_collision() is None def test_warns_when_set(self, monkeypatch): - for var in doctor_mod._CLAUDE_TOKEN_ENV_VARS: + for var in doctor_mod.SHADOWING_CREDENTIAL_ENV_VARS: monkeypatch.delenv(var, raising=False) monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "secret") check = _check_anthropic_env_collision() assert check.status == "warn" assert "ANTHROPIC_AUTH_TOKEN" in check.detail + # ug's settings already blank it for Claude Code; point scripts at ug's own override. + assert "DATABRICKS_BEARER" in check.detail + assert "unset" not in check.detail # Advisory only — no auto-fix for a parent shell's env. assert check.suggestion is None def test_blank_value_is_ignored(self, monkeypatch): - for var in doctor_mod._CLAUDE_TOKEN_ENV_VARS: + for var in doctor_mod.SHADOWING_CREDENTIAL_ENV_VARS: monkeypatch.delenv(var, raising=False) monkeypatch.setenv("ANTHROPIC_API_KEY", " ") assert _check_anthropic_env_collision() is None diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 03353873f..e09d62a8d 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -570,7 +570,7 @@ def test_launch_claude_per_model( "CLAUDE_CONFIG_DIR": str(config_dir), "ANTHROPIC_MODEL": model_id, "ANTHROPIC_BASE_URL": base_url, - "ANTHROPIC_API_KEY": e2e_token, + "DATABRICKS_BEARER": e2e_token, } cmd = claude.validate_cmd("claude") result = _run_agent(cmd, env=env, timeout=90) @@ -652,7 +652,7 @@ def _launch_claude_and_assert_success( **os.environ, "CLAUDE_CONFIG_DIR": str(config_dir), "ANTHROPIC_BASE_URL": build_tool_base_url("claude", workspace), - "ANTHROPIC_API_KEY": token, + "DATABRICKS_BEARER": token, } # Retry the flaky client-side model pre-flight before giving up; only a persistent failure # falls through to _skip_if_provider_unusable. diff --git a/tests/test_e2e_user_agent.py b/tests/test_e2e_user_agent.py index 778aa659e..39c486373 100644 --- a/tests/test_e2e_user_agent.py +++ b/tests/test_e2e_user_agent.py @@ -201,7 +201,7 @@ def test_user_agent_arrives_at_gateway(self, tmp_path, monkeypatch, capture_serv env = { **os.environ, "CLAUDE_CONFIG_DIR": str(config_dir), - "ANTHROPIC_API_KEY": "test-key-not-real", + "DATABRICKS_BEARER": "test-key-not-real", **overlay["env"], } @@ -231,7 +231,7 @@ def test_managed_http_header_arrives_at_gateway(self, tmp_path, monkeypatch, cap env = { **os.environ, "CLAUDE_CONFIG_DIR": str(config_dir), - "ANTHROPIC_API_KEY": "test-key-not-real", + "DATABRICKS_BEARER": "test-key-not-real", **overlay["env"], } diff --git a/tests/test_managed_files.py b/tests/test_managed_files.py index 51292ae6a..f9ab7e50b 100644 --- a/tests/test_managed_files.py +++ b/tests/test_managed_files.py @@ -568,6 +568,7 @@ def test_real_managed_path_helpers_are_allowlisted(self, os_enum, monkeypatch): monkeypatch.setattr(codex_config, "current_os", lambda: os_enum) allowed = managed_files._SUDO_REPLACE_TARGETS[os_enum] assert claude_agent._managed_settings_path() in allowed + assert claude_agent._credentials_dropin_path() in allowed assert codex_config.codex_managed_config_path() in allowed @@ -822,6 +823,15 @@ def test_elevated_headless_run_creates_file(self, windows, monkeypatch): assert windows.read_text(encoding="utf-8") == '{"apiKeyHelper": "ug"}\n' assert [entry.name for entry in windows.parent.iterdir()] == [windows.name] + def test_elevated_run_creates_credential_dropin_folder(self, windows, monkeypatch): + monkeypatch.setattr(managed_files.system_cross_os, "is_admin", lambda: True) + dropin = managed_files.windows_claude_credentials_dropin_path() + assert dropin == windows.parent / "managed-settings.d" / "50-ug-credentials.json" + + assert self._reconcile(dropin, '{"env": {"ANTHROPIC_API_KEY": ""}}\n') == "created" + + assert dropin.read_text(encoding="utf-8") == '{"env": {"ANTHROPIC_API_KEY": ""}}\n' + def test_non_admin_update_quits_with_admin_hint(self, windows, monkeypatch): windows.parent.mkdir(parents=True) windows.write_text('{"apiKeyHelper": "old"}\n', encoding="utf-8") diff --git a/tests/test_managed_model_lifecycle.py b/tests/test_managed_model_lifecycle.py index 7db1becbb..ecfb86a52 100644 --- a/tests/test_managed_model_lifecycle.py +++ b/tests/test_managed_model_lifecycle.py @@ -202,6 +202,8 @@ def harness(tmp_path, monkeypatch): ) def _write_managed(path, text, **kwargs): + # Like the privileged write, create a missing parent such as ug's managed-settings.d. + Path(path).parent.mkdir(parents=True, exist_ok=True) Path(path).write_text(text) return "written" diff --git a/tests/test_managed_resolve.py b/tests/test_managed_resolve.py index f3125411b..798680127 100644 --- a/tests/test_managed_resolve.py +++ b/tests/test_managed_resolve.py @@ -331,6 +331,8 @@ def real_state_file(self, tmp_path, monkeypatch): monkeypatch.setattr(claude, "managed_writes_allowed", lambda: True) def reconcile_managed_file(path, desired_text, **kwargs): + # Like the privileged write, create a missing parent such as ug's managed-settings.d. + path.parent.mkdir(parents=True, exist_ok=True) path.write_text(desired_text, encoding="utf-8") return "written"