From d0e644e5b161b22eb9cf7f6bd3e10b9262eed7a8 Mon Sep 17 00:00:00 2001 From: Luis Cabral Date: Mon, 19 Oct 2026 15:53:27 +0200 Subject: [PATCH 1/5] Drop inherited Anthropic credentials when launching Claude Code Claude Code ranks ANTHROPIC_AUTH_TOKEN and ANTHROPIC_API_KEY above apiKeyHelper and the subscription login. When the user's shell still exports one of them, for example from another LLM gateway setup, `ug claude` hands it to Claude Code, which sends it to the AI Gateway instead of ug's token. Every request then fails with 401 "Credential was not sent or was of an unsupported type for this API" after ten retries, and `ug auth-token` is never called. `claude.launch()` now removes both variables before any launch path (default, relayed, smart routing) starts Claude Code, and warns on stderr when it drops a non-empty value. Co-authored-by: Isaac --- src/ucode/agents/claude.py | 25 +++++++++- tests/README.md | 4 ++ tests/integration/README.md | 2 +- tests/integration/test_ug_claude_headless.py | 42 +++++++++++++++++ tests/test_agent_claude.py | 49 ++++++++++++++++++++ 5 files changed, 120 insertions(+), 2 deletions(-) diff --git a/src/ucode/agents/claude.py b/src/ucode/agents/claude.py index 251d92fb7..e91b25f96 100644 --- a/src/ucode/agents/claude.py +++ b/src/ucode/agents/claude.py @@ -100,7 +100,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 @@ -108,6 +108,10 @@ 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" +# Claude Code ranks these above `apiKeyHelper` and the subscription login. A value inherited from +# the user's shell, such as another LLM gateway's key, would replace ug's credential and every +# gateway request would fail with 401 "Credential was not sent or was of an unsupported type". +SHADOWING_CREDENTIAL_ENV_VARS = ("ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY") CLAUDE_CONFIG_DIR = Path.home() / ".claude" CLAUDE_SETTINGS_PATH = CLAUDE_CONFIG_DIR / "ucode-settings.json" CLAUDE_MCP_CONFIG_PATH = Path.home() / ".claude.json" @@ -2293,12 +2297,31 @@ def token_provider(force_refresh: bool) -> str: raise SystemExit(returncode) +def _drop_shadowing_credentials() -> None: + """Keep inherited Anthropic credentials out of the Claude Code process ug launches. + + ug owns Claude Code's authentication on every launch path (the gateway `apiKeyHelper`, or the + subscription login behind the relayed proxy); see ``SHADOWING_CREDENTIAL_ENV_VARS``. + """ + dropped: list[str] = [] + for name in SHADOWING_CREDENTIAL_ENV_VARS: + if os.environ.pop(name, "").strip(): + dropped.append(name) + if dropped: + # stderr: headless launches keep stdout machine-readable. + print_warning_err( + f"Ignoring {' and '.join(dropped)} from your environment for this session, " + "so Claude Code uses ug's credentials." + ) + + def launch( state: dict, tool_args: list[str], *, options: LaunchOptions, ) -> None: + _drop_shadowing_credentials() tool_args = _external_web_search_args(state, tool_args) binary = SPEC["binary"] workspace = state.get("workspace") diff --git a/tests/README.md b/tests/README.md index 557f67749..aa2d9d207 100644 --- a/tests/README.md +++ b/tests/README.md @@ -131,6 +131,9 @@ 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 default and relayed Claude launches drop inherited +`ANTHROPIC_AUTH_TOKEN`/`ANTHROPIC_API_KEY` before Claude Code starts, warn only on stderr, +and stay silent for empty values. These component checks replace the exec and relay proxy. `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 @@ -400,6 +403,7 @@ integration utilities; only CUJ-specific evidence correlation stays in a test fi | `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_ignores_shell_anthropic_credentials` | Run Claude from a script while the shell exports `ANTHROPIC_AUTH_TOKEN` and `ANTHROPIC_API_KEY` from another LLM gateway | stderr reports both variables as ignored; structured final answer contains the file value; exit zero; no routing | | `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_opencode_headless_prompt_argument` | Run OpenCode from a script (`run --format json --auto`) with an argument prompt | Completed Read tool call; final text answer contains the file value; exit zero (non-blocking CI lane) | | `test_ug_agents_self_managed_opencode_journey` | Under the injected `managed_workspace_default` config (enables Claude/Codex, not OpenCode): bare configure, `ug agents list`, refused OpenCode launch, `ug agents add opencode`, real headless task, `ug agents remove opencode` (marker `managed_fixture and opencode`, non-blocking CI lane) | OpenCode absent from the list and launch fails with "doesn't enable OpenCode" / `ug agents add opencode`; after add it is listed self-managed and the headless Read task returns the fixture value; after remove it is hidden and refused again | diff --git a/tests/integration/README.md b/tests/integration/README.md index 16f484ce9..401b477c1 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -382,7 +382,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..766681e25 100644 --- a/tests/integration/test_ug_claude_headless.py +++ b/tests/integration/test_ug_claude_headless.py @@ -45,6 +45,48 @@ def test_ug_claude_headless_prompt_argument(live_session, workspace): session.assert_not_routed() +def test_ug_claude_headless_ignores_shell_anthropic_credentials(live_session, workspace): + """Scenario: the user's shell still exports another LLM gateway's Anthropic credentials, + then they submit a headless prompt through ug. + + Expected: ug reports on stderr that it ignores both variables, and the real agent still + authenticates through the gateway and returns the file value in its structured completed + answer, with exit code zero and JSON-only stdout. Claude Code ranks both variables above + ug's apiKeyHelper, so before this was handled every request failed with 401. + """ + session = live_session + task = FileTask(session) + session.run( + "configure", + "--agents", + "claude", + "--workspace", + workspace, + "--skip-validate", + "--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", + "--", + "-p", + task.prompt, + "--output-format", + "json", + "--allowedTools", + "Read", + timeout=180, + ) + assert json.loads(result.stdout)["type"] == "result" + task.assert_headless_answer("claude", result) + assert "ANTHROPIC_AUTH_TOKEN and ANTHROPIC_API_KEY" in result.stderr + session.assert_not_routed() + + def test_ug_claude_headless_prompt_stdin(live_session, workspace): """Scenario: configure claude and submit a headless prompt via stdin. diff --git a/tests/test_agent_claude.py b/tests/test_agent_claude.py index fa4162d09..e126bec09 100644 --- a/tests/test_agent_claude.py +++ b/tests/test_agent_claude.py @@ -3201,6 +3201,55 @@ 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_drops_shadowing_shell_credentials(self, monkeypatch, capsys): + # Claude Code would send these instead of the gateway apiKeyHelper token (401). + launched: list[dict[str, str | None]] = [] + 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") + monkeypatch.setattr(claude, "get_databricks_token", lambda *_args: "token") + monkeypatch.setattr( + claude, + "exec_or_spawn", + lambda _argv: launched.append( + {name: os.environ.get(name) for name in claude.SHADOWING_CREDENTIAL_ENV_VARS} + ), + ) + + claude.launch({"workspace": WS, "profile": "test"}, ["-p", "hi"], options=LaunchOptions()) + + assert launched == [{"ANTHROPIC_AUTH_TOKEN": None, "ANTHROPIC_API_KEY": None}] + captured = capsys.readouterr() + assert "ANTHROPIC_AUTH_TOKEN and ANTHROPIC_API_KEY" in captured.err + assert "ANTHROPIC" not in captured.out + + def test_relayed_launch_drops_shadowing_shell_credentials(self, monkeypatch): + launched: list[dict[str, str | None]] = [] + monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "sk-other-gateway") + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.setattr( + claude, + "_launch_relayed", + lambda _state, _binary, _tool_args: launched.append( + {name: os.environ.get(name) for name in claude.SHADOWING_CREDENTIAL_ENV_VARS} + ), + ) + + claude.launch({"workspace": WS, "claude_relayed": True}, [], options=LaunchOptions()) + + assert launched == [{"ANTHROPIC_AUTH_TOKEN": None, "ANTHROPIC_API_KEY": None}] + + def test_launch_ignores_empty_credentials_without_warning(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_AUTH_TOKEN" not in os.environ + assert "Ignoring" 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' From dad20cf2d8a4352e37663d863143c8a27bff5e31 Mon Sep 17 00:00:00 2001 From: Luis Cabral Date: Tue, 20 Oct 2026 17:43:51 +0200 Subject: [PATCH 2/5] Blank inherited Anthropic credentials in ug's Claude Code settings Rework the fix after review: instead of removing ANTHROPIC_AUTH_TOKEN and ANTHROPIC_API_KEY from the environment at launch, ug now writes both as empty strings in the settings env block it already owns, next to ANTHROPIC_BASE_URL. Claude Code applies settings env over the shell, so a credential left by another LLM gateway setup can no longer outrank apiKeyHelper. Because the block is mirrored into the OS-managed file, this also covers a bare `claude`, and `ug revert` removes the keys with the rest of ug's fields. The user's shell is left untouched. `ug claude` keeps a one-line stderr note when either variable is set. The e2e and user-agent tests pass their token through DATABRICKS_BEARER, since ug's settings now blank ANTHROPIC_API_KEY. Co-authored-by: Isaac --- AGENTS.md | 1 + src/ucode/agents/claude.py | 25 ++++---- tests/README.md | 7 +- tests/integration/test_ug_claude_headless.py | 8 +-- tests/test_agent_claude.py | 67 ++++++++++++-------- tests/test_e2e.py | 4 +- tests/test_e2e_user_agent.py | 4 +- 7 files changed, 69 insertions(+), 47 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index dc66511c2..80eec2e1a 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 | | `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 e91b25f96..83eb61d41 100644 --- a/src/ucode/agents/claude.py +++ b/src/ucode/agents/claude.py @@ -111,6 +111,7 @@ # Claude Code ranks these above `apiKeyHelper` and the subscription login. A value inherited from # the user's shell, such as another LLM gateway's key, would replace ug's credential and every # gateway request would fail with 401 "Credential was not sent or was of an unsupported type". +# ug writes them as empty strings in its settings `env`, which Claude Code applies over the shell. SHADOWING_CREDENTIAL_ENV_VARS = ("ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY") CLAUDE_CONFIG_DIR = Path.home() / ".claude" CLAUDE_SETTINGS_PATH = CLAUDE_CONFIG_DIR / "ucode-settings.json" @@ -532,6 +533,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 @@ -2297,21 +2300,21 @@ def token_provider(force_refresh: bool) -> str: raise SystemExit(returncode) -def _drop_shadowing_credentials() -> None: - """Keep inherited Anthropic credentials out of the Claude Code process ug launches. +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); see ``SHADOWING_CREDENTIAL_ENV_VARS``. + subscription login behind the relayed proxy). Its settings blank these variables; see + ``SHADOWING_CREDENTIAL_ENV_VARS``. The user's environment itself is left unchanged. """ - dropped: list[str] = [] - for name in SHADOWING_CREDENTIAL_ENV_VARS: - if os.environ.pop(name, "").strip(): - dropped.append(name) - if dropped: + shadowing = [name for name in SHADOWING_CREDENTIAL_ENV_VARS if os.environ.get(name, "").strip()] + if shadowing: + verb, pronoun = ("is", "it") if len(shadowing) == 1 else ("are", "them") # stderr: headless launches keep stdout machine-readable. print_warning_err( - f"Ignoring {' and '.join(dropped)} from your environment for this session, " - "so Claude Code uses ug's credentials." + f"{' and '.join(shadowing)} from your environment {verb} ignored by Claude Code " + f"sessions ug configures, which use ug's credentials. Commands Claude runs see " + f"{pronoun} empty." ) @@ -2321,7 +2324,7 @@ def launch( *, options: LaunchOptions, ) -> None: - _drop_shadowing_credentials() + _warn_shadowing_credentials() tool_args = _external_web_search_args(state, tool_args) binary = SPEC["binary"] workspace = state.get("workspace") diff --git a/tests/README.md b/tests/README.md index 48a141396..3734fa608 100644 --- a/tests/README.md +++ b/tests/README.md @@ -146,9 +146,10 @@ 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 default and relayed Claude launches drop inherited -`ANTHROPIC_AUTH_TOKEN`/`ANTHROPIC_API_KEY` before Claude Code starts, warn only on stderr, -and stay silent for empty values. These component checks replace the exec and relay proxy. +`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 and OS-managed files, for default and relayed +setups, and that 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_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 diff --git a/tests/integration/test_ug_claude_headless.py b/tests/integration/test_ug_claude_headless.py index 766681e25..0f27808d0 100644 --- a/tests/integration/test_ug_claude_headless.py +++ b/tests/integration/test_ug_claude_headless.py @@ -49,10 +49,10 @@ def test_ug_claude_headless_ignores_shell_anthropic_credentials(live_session, wo """Scenario: the user's shell still exports another LLM gateway's Anthropic credentials, then they submit a headless prompt through ug. - Expected: ug reports on stderr that it ignores both variables, and the real agent still - authenticates through the gateway and returns the file value in its structured completed - answer, with exit code zero and JSON-only stdout. Claude Code ranks both variables above - ug's apiKeyHelper, so before this was handled every request failed with 401. + Expected: ug's settings blank both variables for Claude Code and ug notes this on stderr. + The real agent still authenticates through the gateway and returns the file value in its + structured completed answer, with exit code zero and JSON-only stdout. Claude Code ranks both + variables above ug's apiKeyHelper, so before this was handled every request failed with 401. """ session = live_session task = FileTask(session) diff --git a/tests/test_agent_claude.py b/tests/test_agent_claude.py index e126bec09..f9d9d9985 100644 --- a/tests/test_agent_claude.py +++ b/tests/test_agent_claude.py @@ -292,6 +292,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: @@ -2636,6 +2647,25 @@ 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_land_in_both_files_and_are_revertible(self, monkeypatch): + # The managed file covers a bare `claude` too; an existing value there is replaced like + # any other ug-owned leaf, and the recorded keys let `ug revert` remove the blanks again. + 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)) + managed = json.loads(managed_writes[0][1]) + for name in claude.SHADOWING_CREDENTIAL_ENV_VARS: + assert private["env"][name] == "" + assert managed["env"][name] == "" + assert ["env", name] in state["managed_configs"]["claude"]["keys"] + def test_settings_passthrough_lands_in_managed_file_only(self, monkeypatch): private_writes: list = [] managed_writes: list = [] @@ -3201,54 +3231,41 @@ 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_drops_shadowing_shell_credentials(self, monkeypatch, capsys): - # Claude Code would send these instead of the gateway apiKeyHelper token (401). - launched: list[dict[str, str | None]] = [] + 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") monkeypatch.setattr(claude, "get_databricks_token", lambda *_args: "token") - monkeypatch.setattr( - claude, - "exec_or_spawn", - lambda _argv: launched.append( - {name: os.environ.get(name) for name in claude.SHADOWING_CREDENTIAL_ENV_VARS} - ), - ) + monkeypatch.setattr(claude, "exec_or_spawn", lambda _argv: None) claude.launch({"workspace": WS, "profile": "test"}, ["-p", "hi"], options=LaunchOptions()) - assert launched == [{"ANTHROPIC_AUTH_TOKEN": None, "ANTHROPIC_API_KEY": None}] + assert os.environ["ANTHROPIC_AUTH_TOKEN"] == "sk-other-gateway" + assert os.environ["ANTHROPIC_API_KEY"] == "sk-other-gateway" captured = capsys.readouterr() assert "ANTHROPIC_AUTH_TOKEN and ANTHROPIC_API_KEY" in captured.err assert "ANTHROPIC" not in captured.out - def test_relayed_launch_drops_shadowing_shell_credentials(self, monkeypatch): - launched: list[dict[str, str | None]] = [] + def test_relayed_launch_warns_on_shadowing_credentials(self, monkeypatch, capsys): monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "sk-other-gateway") monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) - monkeypatch.setattr( - claude, - "_launch_relayed", - lambda _state, _binary, _tool_args: launched.append( - {name: os.environ.get(name) for name in claude.SHADOWING_CREDENTIAL_ENV_VARS} - ), - ) + monkeypatch.setattr(claude, "_launch_relayed", lambda *_args: None) claude.launch({"workspace": WS, "claude_relayed": True}, [], options=LaunchOptions()) - assert launched == [{"ANTHROPIC_AUTH_TOKEN": None, "ANTHROPIC_API_KEY": None}] + assert os.environ["ANTHROPIC_AUTH_TOKEN"] == "sk-other-gateway" + assert "ANTHROPIC_AUTH_TOKEN from your environment" in capsys.readouterr().err - def test_launch_ignores_empty_credentials_without_warning(self, monkeypatch, capsys): - monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "") + 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_AUTH_TOKEN" not in os.environ - assert "Ignoring" not in capsys.readouterr().err + 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" diff --git a/tests/test_e2e.py b/tests/test_e2e.py index adc406947..14a7ad08c 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -568,7 +568,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) @@ -650,7 +650,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"], } From 4da54a94a53463a751dc84be714c7434f0d1e907 Mon Sep 17 00:00:00 2001 From: Luis Cabral Date: Tue, 20 Oct 2026 17:54:39 +0200 Subject: [PATCH 3/5] Point scripted PAT users at DATABRICKS_BEARER in the launch warning Scripts that authenticated Claude Code with a PAT in ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY now get those values blanked by ug's settings. The warning tells them to set DATABRICKS_BEARER, which ug's apiKeyHelper returns as is. Co-authored-by: Isaac --- src/ucode/agents/claude.py | 2 +- tests/test_agent_claude.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/ucode/agents/claude.py b/src/ucode/agents/claude.py index 83eb61d41..66a3b4c3a 100644 --- a/src/ucode/agents/claude.py +++ b/src/ucode/agents/claude.py @@ -2314,7 +2314,7 @@ def _warn_shadowing_credentials() -> None: print_warning_err( f"{' and '.join(shadowing)} from your environment {verb} ignored by Claude Code " f"sessions ug configures, which use ug's credentials. Commands Claude runs see " - f"{pronoun} empty." + f"{pronoun} empty. To authenticate with a PAT in scripts, set DATABRICKS_BEARER instead." ) diff --git a/tests/test_agent_claude.py b/tests/test_agent_claude.py index f9d9d9985..353a5cedc 100644 --- a/tests/test_agent_claude.py +++ b/tests/test_agent_claude.py @@ -3245,7 +3245,10 @@ def test_launch_warns_on_shadowing_credentials_and_keeps_env(self, monkeypatch, assert os.environ["ANTHROPIC_AUTH_TOKEN"] == "sk-other-gateway" assert os.environ["ANTHROPIC_API_KEY"] == "sk-other-gateway" captured = capsys.readouterr() - assert "ANTHROPIC_AUTH_TOKEN and ANTHROPIC_API_KEY" in captured.err + 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_relayed_launch_warns_on_shadowing_credentials(self, monkeypatch, capsys): From f793068a7a5ff273dd6d31dda59c0458528f7292 Mon Sep 17 00:00:00 2001 From: Luis Cabral Date: Wed, 21 Oct 2026 17:25:52 +0200 Subject: [PATCH 4/5] Address review: one credential message, shared constant, leaner tests - The launch warning always names both variables, without singular and plural variants. - `ug doctor` reuses SHADOWING_CREDENTIAL_ENV_VARS and the launch warning's wording instead of telling users to unset the variables, which ug's settings now blank for Claude Code. - Fold the shell-credentials scenario into the existing headless prompt-argument journey instead of a separate live test. - Keep one launch warning test: the warning runs before the relayed branch. Register OAUTH_TOKEN so the warning test cleans it up. - Shorten the constant's comment. Co-authored-by: Isaac --- src/ucode/agents/claude.py | 24 +++++----- src/ucode/doctor.py | 26 ++++------- tests/README.md | 3 +- tests/integration/test_ug_claude_headless.py | 48 +++----------------- tests/test_agent_claude.py | 12 +---- tests/test_doctor.py | 9 ++-- 6 files changed, 35 insertions(+), 87 deletions(-) diff --git a/src/ucode/agents/claude.py b/src/ucode/agents/claude.py index 0c6e99565..578a42d4c 100644 --- a/src/ucode/agents/claude.py +++ b/src/ucode/agents/claude.py @@ -109,10 +109,7 @@ 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" -# Claude Code ranks these above `apiKeyHelper` and the subscription login. A value inherited from -# the user's shell, such as another LLM gateway's key, would replace ug's credential and every -# gateway request would fail with 401 "Credential was not sent or was of an unsupported type". -# ug writes them as empty strings in its settings `env`, which Claude Code applies over the shell. +# Blank inherited credentials so Claude uses ug's configured authentication. SHADOWING_CREDENTIAL_ENV_VARS = ("ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY") CLAUDE_CONFIG_DIR = Path.home() / ".claude" CLAUDE_SETTINGS_PATH = CLAUDE_CONFIG_DIR / "ucode-settings.json" @@ -2302,6 +2299,15 @@ 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. @@ -2309,15 +2315,9 @@ def _warn_shadowing_credentials() -> None: subscription login behind the relayed proxy). Its settings blank these variables; see ``SHADOWING_CREDENTIAL_ENV_VARS``. The user's environment itself is left unchanged. """ - shadowing = [name for name in SHADOWING_CREDENTIAL_ENV_VARS if os.environ.get(name, "").strip()] - if shadowing: - verb, pronoun = ("is", "it") if len(shadowing) == 1 else ("are", "them") + if any(os.environ.get(name, "").strip() for name in SHADOWING_CREDENTIAL_ENV_VARS): # stderr: headless launches keep stdout machine-readable. - print_warning_err( - f"{' and '.join(shadowing)} from your environment {verb} ignored by Claude Code " - f"sessions ug configures, which use ug's credentials. Commands Claude runs see " - f"{pronoun} empty. To authenticate with a PAT in scripts, set DATABRICKS_BEARER instead." - ) + print_warning_err(shadowing_credentials_message()) def launch( diff --git a/src/ucode/doctor.py b/src/ucode/doctor.py index 85ec1513a..d5755cc37 100644 --- a/src/ucode/doctor.py +++ b/src/ucode/doctor.py @@ -20,6 +20,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, @@ -45,11 +46,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 = { @@ -225,22 +221,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/tests/README.md b/tests/README.md index 940242dfc..b0dc1d82a 100644 --- a/tests/README.md +++ b/tests/README.md @@ -462,8 +462,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_ignores_shell_anthropic_credentials` | Run Claude from a script while the shell exports `ANTHROPIC_AUTH_TOKEN` and `ANTHROPIC_API_KEY` from another LLM gateway | stderr reports both variables as ignored; 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_opencode_headless_prompt_argument` | Run OpenCode from a script (`run --format json --auto`) with an argument prompt | Completed Read tool call; final text answer contains the file value; exit zero (non-blocking CI lane) | | `test_ug_agents_self_managed_opencode_journey` | Under the injected `managed_workspace_default` config (enables Claude/Codex, not OpenCode): bare configure, `ug agents list`, refused OpenCode launch, `ug agents add opencode`, real headless task, `ug agents remove opencode` (marker `managed_fixture and opencode`, non-blocking CI lane) | OpenCode absent from the list and launch fails with "doesn't enable OpenCode" / `ug agents add opencode`; after add it is listed self-managed and the headless Read task returns the fixture value; after remove it is hidden and refused again | diff --git a/tests/integration/test_ug_claude_headless.py b/tests/integration/test_ug_claude_headless.py index 0f27808d0..f5bd8deed 100644 --- a/tests/integration/test_ug_claude_headless.py +++ b/tests/integration/test_ug_claude_headless.py @@ -11,48 +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. - """ - session = live_session - task = FileTask(session) - session.run( - "configure", - "--agents", - "claude", - "--workspace", - workspace, - "--skip-validate", - "--skip-upgrade", - "--disable-databricks-ai-tools", - ) - - result = session.run( - "claude", - "--", - "-p", - task.prompt, - "--output-format", - "json", - "--allowedTools", - "Read", - timeout=180, - ) - assert json.loads(result.stdout)["type"] == "result" - task.assert_headless_answer("claude", result) - session.assert_not_routed() - - -def test_ug_claude_headless_ignores_shell_anthropic_credentials(live_session, workspace): - """Scenario: the user's shell still exports another LLM gateway's Anthropic credentials, - then they submit a headless prompt through ug. - - Expected: ug's settings blank both variables for Claude Code and ug notes this on stderr. - The real agent still authenticates through the gateway and returns the file value in its - structured completed answer, with exit code zero and JSON-only stdout. Claude Code ranks both - variables above ug's apiKeyHelper, so before this was handled every request failed with 401. + 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) @@ -83,7 +47,7 @@ def test_ug_claude_headless_ignores_shell_anthropic_credentials(live_session, wo ) assert json.loads(result.stdout)["type"] == "result" task.assert_headless_answer("claude", result) - assert "ANTHROPIC_AUTH_TOKEN and ANTHROPIC_API_KEY" in result.stderr + 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 aff8148ac..9a373082f 100644 --- a/tests/test_agent_claude.py +++ b/tests/test_agent_claude.py @@ -3254,6 +3254,8 @@ def test_launch_warns_on_shadowing_credentials_and_keeps_env(self, monkeypatch, 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) @@ -3268,16 +3270,6 @@ def test_launch_warns_on_shadowing_credentials_and_keeps_env(self, monkeypatch, assert "set DATABRICKS_BEARER instead" in err assert "ANTHROPIC" not in captured.out - def test_relayed_launch_warns_on_shadowing_credentials(self, monkeypatch, capsys): - monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "sk-other-gateway") - 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 os.environ["ANTHROPIC_AUTH_TOKEN"] == "sk-other-gateway" - assert "ANTHROPIC_AUTH_TOKEN from your environment" in capsys.readouterr().err - def test_launch_does_not_warn_for_empty_credentials(self, monkeypatch, capsys): monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", " ") monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 45ea62c40..03f40a819 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -201,22 +201,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 From 8d4d263b2aa513b87c86f1d17ffc0b0a34f3f1e9 Mon Sep 17 00:00:00 2001 From: Luis Cabral Date: Thu, 22 Oct 2026 10:44:33 +0200 Subject: [PATCH 5/5] Move ug's blank credentials into a managed-settings.d drop-in Writing the blank ANTHROPIC_AUTH_TOKEN / ANTHROPIC_API_KEY into managed-settings.json clashes with any other program that templates that file (CUJ 8's second writer): it drops the keys, ug re-adds them, and ug asks for sudo on every launch. ug now writes them to its own drop-in, managed-settings.d/50-ug-credentials.json, beside the managed file. Claude Code merges drop-ins after managed-settings.json, env key by key, so a bare `claude` stays covered while a templating writer never touches it. managed-settings.json keeps whatever values it already had for the two variables, and ug no longer owns them there. - The drop-in is written in the same privileged session as the managed file, so it never costs a second password prompt, and is skipped when privileged writes are unavailable. - Both sudo allowlists and the Windows target check accept the path. - `ug revert` removes the drop-in and its folder when that is empty. - ucode-settings.json still carries the blanks for `ug claude`. Co-authored-by: Isaac --- AGENTS.md | 2 +- src/ucode/agents/claude.py | 128 +++++++++++++++++++---- src/ucode/managed_files.py | 48 ++++++++- src/ucode/managed_replace.sh | 2 + tests/README.md | 10 +- tests/test_agent_claude.py | 143 +++++++++++++++++++++++--- tests/test_managed_files.py | 10 ++ tests/test_managed_model_lifecycle.py | 2 + tests/test_managed_resolve.py | 2 + 9 files changed, 306 insertions(+), 41 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 30802bc77..8ada99b10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -123,7 +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 | +| `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 123332daf..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, @@ -112,6 +115,9 @@ 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" @@ -297,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) @@ -396,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]: @@ -1606,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, @@ -1631,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( @@ -1932,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( 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 b8e75df6e..a732b68b6 100644 --- a/tests/README.md +++ b/tests/README.md @@ -181,9 +181,13 @@ 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 and OS-managed files, for default and relayed -setups, and that 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. +`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 diff --git a/tests/test_agent_claude.py b/tests/test_agent_claude.py index fa96aea49..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" @@ -996,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: @@ -1093,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]) @@ -1408,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) @@ -1470,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) @@ -1504,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. @@ -2684,9 +2782,10 @@ 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_land_in_both_files_and_are_revertible(self, monkeypatch): - # The managed file covers a bare `claude` too; an existing value there is replaced like - # any other ug-owned leaf, and the recorded keys let `ug revert` remove the blanks again. + 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"}}} @@ -2697,12 +2796,26 @@ def test_blank_credentials_land_in_both_files_and_are_revertible(self, monkeypat ) private = next(p for path, p in private_writes if path == str(claude.CLAUDE_SETTINGS_PATH)) - managed = json.loads(managed_writes[0][1]) + 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 managed["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 = [] 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"