| 1 | """Unsubstituted `${user_config.*}` placeholders must read as unconfigured. |
| 2 | |
| 3 | A Claude Desktop extension writes the literal placeholder for every field the |
| 4 | user has not filled in. The value is non-empty, so before this fix every |
| 5 | presence check downstream read it as a real credential: doctor reported the |
| 6 | source healthy, preflight returned ready, and the backend sent the placeholder |
| 7 | upstream and surfaced the vendor's auth error instead of falling back. |
| 8 | """ |
| 9 | |
| 10 | from __future__ import annotations |
| 11 | |
| 12 | from unittest import mock |
| 13 | |
| 14 | from lib import env |
| 15 | |
| 16 | |
| 17 | TEMPLATE = "${user_config.scrapecreators_api_key}" |
| 18 | |
| 19 | _KEYS = ( |
| 20 | "SCRAPECREATORS_API_KEY", |
| 21 | "SCRAPE_CREATORS_API_KEY", |
| 22 | "GEMINI_API_KEY", |
| 23 | "OPENAI_API_KEY", |
| 24 | "GITHUB_TOKEN", |
| 25 | "LAST30DAYS_MEMORY_DIR", |
| 26 | "LAST30DAYS_YT_PLAYER_CLIENT", |
| 27 | ) |
| 28 | |
| 29 | |
| 30 | def _isolate(monkeypatch, tmp_path): |
| 31 | """Point config loading at an empty world so only the test's env applies.""" |
| 32 | monkeypatch.setattr(env, "CONFIG_FILE", tmp_path / "does-not-exist.env") |
| 33 | monkeypatch.setattr(env, "_find_project_env", lambda: None) |
| 34 | monkeypatch.setattr(env, "_load_keychain", lambda *a, **k: {}) |
| 35 | monkeypatch.setattr(env, "_load_pass", lambda *a, **k: {}) |
| 36 | monkeypatch.chdir(tmp_path) |
| 37 | for key in _KEYS: |
| 38 | monkeypatch.delenv(key, raising=False) |
| 39 | # Drop any ambient SETUP_COMPLETE so a truthy value on the machine running |
| 40 | # the suite cannot leak into the config under test. |
| 41 | monkeypatch.delenv("SETUP_COMPLETE", raising=False) |
| 42 | |
| 43 | |
| 44 | def _isolate_with_env_file(monkeypatch, tmp_path, contents): |
| 45 | """Like ``_isolate``, but the global .env holds real values.""" |
| 46 | _isolate(monkeypatch, tmp_path) |
| 47 | config_file = tmp_path / ".env" |
| 48 | config_file.write_text(contents, encoding="utf-8") |
| 49 | config_file.chmod(0o600) |
| 50 | monkeypatch.setattr(env, "CONFIG_FILE", config_file) |
| 51 | |
| 52 | |
| 53 | def test_placeholder_falls_through_to_a_real_lower_priority_credential(monkeypatch, tmp_path): |
| 54 | # The host's placeholder occupies the highest-priority source. It must read |
| 55 | # as absent, not as an empty override, or the credential the user already |
| 56 | # configured in .env (or Keychain, or pass) is silently discarded and the |
| 57 | # run degrades despite having a valid key. |
| 58 | _isolate_with_env_file( |
| 59 | monkeypatch, tmp_path, "SCRAPECREATORS_API_KEY=sc_real_from_dotenv\n" |
| 60 | ) |
| 61 | monkeypatch.setenv("SCRAPECREATORS_API_KEY", TEMPLATE) |
| 62 | |
| 63 | config = env.get_config() |
| 64 | |
| 65 | assert config["SCRAPECREATORS_API_KEY"] == "sc_real_from_dotenv" |
| 66 | # A credential that resolved is configured, so it is not reported as unset. |
| 67 | assert "SCRAPECREATORS_API_KEY" not in config[env.TEMPLATE_CONFIG_KEYS] |
| 68 | # The placeholder itself still leaves the process environment. |
| 69 | assert env.read_secret_env("SCRAPECREATORS_API_KEY") is None |
| 70 | |
| 71 | |
| 72 | def test_a_restored_key_list_is_still_rotated_to_a_single_key(monkeypatch, tmp_path): |
| 73 | # The rotation runs before the sweep, so a list restored from a lower-priority |
| 74 | # source must be rotated again - otherwise the backend receives "k1,k2" as one |
| 75 | # credential and authentication fails despite valid fallback keys existing. |
| 76 | _isolate_with_env_file( |
| 77 | monkeypatch, tmp_path, "SCRAPECREATORS_API_KEY=sc_one,sc_two\n" |
| 78 | ) |
| 79 | monkeypatch.setenv("SCRAPECREATORS_API_KEY", TEMPLATE) |
| 80 | |
| 81 | config = env.get_config() |
| 82 | |
| 83 | assert config["SCRAPECREATORS_API_KEY"] in {"sc_one", "sc_two"} |
| 84 | assert "," not in config["SCRAPECREATORS_API_KEY"] |
| 85 | |
| 86 | |
| 87 | def test_a_lower_priority_placeholder_is_not_a_fallback(monkeypatch, tmp_path): |
| 88 | # A placeholder in .env is no more a credential than one in the environment. |
| 89 | _isolate_with_env_file( |
| 90 | monkeypatch, tmp_path, f"SCRAPECREATORS_API_KEY={TEMPLATE}\n" |
| 91 | ) |
| 92 | monkeypatch.setenv("SCRAPECREATORS_API_KEY", TEMPLATE) |
| 93 | |
| 94 | config = env.get_config() |
| 95 | |
| 96 | assert config["SCRAPECREATORS_API_KEY"] == "" |
| 97 | assert "SCRAPECREATORS_API_KEY" in config[env.TEMPLATE_CONFIG_KEYS] |
| 98 | |
| 99 | |
| 100 | def test_templated_openai_key_clears_the_derived_auth_record(monkeypatch, tmp_path): |
| 101 | _isolate(monkeypatch, tmp_path) |
| 102 | monkeypatch.setenv("OPENAI_API_KEY", "${user_config.openai_api_key}") |
| 103 | |
| 104 | config = env.get_config() |
| 105 | |
| 106 | assert config["OPENAI_API_KEY"] == "" |
| 107 | assert config["OPENAI_AUTH_STATUS"] == env.AUTH_STATUS_MISSING |
| 108 | assert config["OPENAI_AUTH_SOURCE"] == env.AUTH_SOURCE_NONE |
| 109 | assert "OPENAI_API_KEY" in config[env.TEMPLATE_CONFIG_KEYS] |
| 110 | |
| 111 | |
| 112 | def test_whole_value_template_is_emptied_and_recorded(monkeypatch, tmp_path): |
| 113 | _isolate(monkeypatch, tmp_path) |
| 114 | monkeypatch.setenv("SCRAPECREATORS_API_KEY", TEMPLATE) |
| 115 | |
| 116 | config = env.get_config() |
| 117 | |
| 118 | assert config["SCRAPECREATORS_API_KEY"] == "" |
| 119 | assert "SCRAPECREATORS_API_KEY" in config[env.TEMPLATE_CONFIG_KEYS] |
| 120 | |
| 121 | |
| 122 | def test_template_is_removed_from_the_process_environment(monkeypatch, tmp_path): |
| 123 | _isolate(monkeypatch, tmp_path) |
| 124 | monkeypatch.setenv("GITHUB_TOKEN", "${user_config.github_token}") |
| 125 | |
| 126 | env.get_config() |
| 127 | |
| 128 | # doctor's GitHub record and the GitHub backend read this name straight |
| 129 | # from os.environ, bypassing the config dict entirely. |
| 130 | assert env.read_secret_env("GITHUB_TOKEN") is None |
| 131 | |
| 132 | |
| 133 | def test_real_credential_is_untouched_and_stays_in_the_environment(monkeypatch, tmp_path): |
| 134 | _isolate(monkeypatch, tmp_path) |
| 135 | monkeypatch.setenv("SCRAPECREATORS_API_KEY", "sc_abc123") |
| 136 | |
| 137 | config = env.get_config() |
| 138 | |
| 139 | assert config["SCRAPECREATORS_API_KEY"] == "sc_abc123" |
| 140 | assert config[env.TEMPLATE_CONFIG_KEYS] == [] |
| 141 | assert env.read_secret_env("SCRAPECREATORS_API_KEY") == "sc_abc123" |
| 142 | |
| 143 | |
| 144 | def test_placeholder_alongside_other_text_is_kept(monkeypatch, tmp_path): |
| 145 | _isolate(monkeypatch, tmp_path) |
| 146 | monkeypatch.setenv("SCRAPECREATORS_API_KEY", "prefix-${user_config.x}-suffix") |
| 147 | |
| 148 | config = env.get_config() |
| 149 | |
| 150 | assert config["SCRAPECREATORS_API_KEY"] == "prefix-${user_config.x}-suffix" |
| 151 | assert config[env.TEMPLATE_CONFIG_KEYS] == [] |
| 152 | |
| 153 | |
| 154 | def test_shell_default_syntax_is_not_a_template(monkeypatch, tmp_path): |
| 155 | # SKILL.md itself ships this shape as a .env example. |
| 156 | _isolate(monkeypatch, tmp_path) |
| 157 | shell_default = "${LAST30DAYS_MEMORY_DIR:-$HOME/Documents/Last30Days}" |
| 158 | monkeypatch.setenv("LAST30DAYS_MEMORY_DIR", shell_default) |
| 159 | |
| 160 | config = env.get_config() |
| 161 | |
| 162 | assert config["LAST30DAYS_MEMORY_DIR"] == shell_default |
| 163 | assert config[env.TEMPLATE_CONFIG_KEYS] == [] |
| 164 | |
| 165 | |
| 166 | def test_key_assembled_before_the_registered_key_loop_is_covered(monkeypatch, tmp_path): |
| 167 | # OPENAI_API_KEY is set from get_openai_auth() before the `keys` loop. |
| 168 | _isolate(monkeypatch, tmp_path) |
| 169 | monkeypatch.setenv("OPENAI_API_KEY", "${user_config.openai_api_key}") |
| 170 | |
| 171 | config = env.get_config() |
| 172 | |
| 173 | assert config["OPENAI_API_KEY"] == "" |
| 174 | assert "OPENAI_API_KEY" in config[env.TEMPLATE_CONFIG_KEYS] |
| 175 | |
| 176 | |
| 177 | def test_templated_legacy_spelling_does_not_repopulate_the_canonical_key( |
| 178 | monkeypatch, tmp_path |
| 179 | ): |
| 180 | _isolate(monkeypatch, tmp_path) |
| 181 | monkeypatch.setenv("SCRAPE_CREATORS_API_KEY", "${user_config.scrapecreators_api_key}") |
| 182 | |
| 183 | config = env.get_config() |
| 184 | |
| 185 | assert config["SCRAPECREATORS_API_KEY"] == "" |
| 186 | |
| 187 | |
| 188 | def test_templated_credential_reads_as_absent_to_diagnose(monkeypatch, tmp_path): |
| 189 | _isolate(monkeypatch, tmp_path) |
| 190 | monkeypatch.setenv("GEMINI_API_KEY", "${user_config.gemini_api_key}") |
| 191 | |
| 192 | config = env.get_config() |
| 193 | with mock.patch("lib.bird_x.get_bird_status", return_value={ |
| 194 | "installed": False, "authenticated": False, "username": None, |
| 195 | "can_install": False, |
| 196 | }), mock.patch("lib.bird_x.set_credentials", lambda *a, **k: None), \ |
| 197 | mock.patch("lib.grok_x.has_stored_auth", return_value=False), \ |
| 198 | mock.patch("lib.xurl_x.has_stored_auth", return_value=False): |
| 199 | from lib import pipeline |
| 200 | |
| 201 | diag = pipeline.diagnose(config, None, safe=True) |
| 202 | |
| 203 | assert diag["providers"]["google"] is False |
| 204 | |
| 205 | |
| 206 | def test_key_the_earlier_export_loop_pushed_out_is_cleared_too(monkeypatch, tmp_path): |
| 207 | # The YT knob export loop runs before the sweep and passes this key through |
| 208 | # on `is not None`, so the sweep has to clear what that loop just exported. |
| 209 | _isolate(monkeypatch, tmp_path) |
| 210 | monkeypatch.setenv("LAST30DAYS_YT_PLAYER_CLIENT", "${user_config.player_client}") |
| 211 | |
| 212 | config = env.get_config() |
| 213 | |
| 214 | assert config["LAST30DAYS_YT_PLAYER_CLIENT"] == "" |
| 215 | assert env.read_secret_env("LAST30DAYS_YT_PLAYER_CLIENT") is None |
| 216 | |
| 217 | |
| 218 | def test_no_templates_leaves_an_empty_record(monkeypatch, tmp_path): |
| 219 | _isolate(monkeypatch, tmp_path) |
| 220 | |
| 221 | config = env.get_config() |
| 222 | |
| 223 | assert config[env.TEMPLATE_CONFIG_KEYS] == [] |
| 224 | |
| 225 | |
| 226 | def test_is_unsubstituted_template_matches_only_the_whole_placeholder(): |
| 227 | assert env.is_unsubstituted_template(TEMPLATE) is True |
| 228 | assert env.is_unsubstituted_template(" ${user_config.x} ") is True |
| 229 | assert env.is_unsubstituted_template("${user_config.x} ") is True |
| 230 | assert env.is_unsubstituted_template("${user_config.x}-tail") is False |
| 231 | assert env.is_unsubstituted_template("${LAST30DAYS_MEMORY_DIR:-x}") is False |
| 232 | assert env.is_unsubstituted_template("") is False |
| 233 | assert env.is_unsubstituted_template(None) is False |
| 234 | assert env.is_unsubstituted_template(1234) is False |
| 235 | |
| 236 | |
| 237 | def test_shell_default_in_the_extension_namespace_is_not_a_template(): |
| 238 | # `${user_config.x:-default}` is shell-default syntax, not an unexpanded |
| 239 | # placeholder: the field name is not the bare identifier the manifest emits. |
| 240 | assert env.is_unsubstituted_template("${user_config.x:-default}") is False |
| 241 | assert env.is_unsubstituted_template("${user_config.}") is False |
| 242 | assert env.is_unsubstituted_template("${user_config.a}${user_config.b}") is False |
| 243 | assert env.is_unsubstituted_template("${user_config.x y}") is False |
| 244 |