Skip to content

Commit 63ac368

Browse files
Siaochuantjb-tech
authored andcommitted
fix(auth): prefer scoped provider env keys
Prefer OPENHARNESS_<PROVIDER>_API_KEY variables over provider-native globals while resolving API-key auth. Keep provider-native variables as fallback and avoid applying unrelated native keys across active profiles. Forward the OpenHarness-scoped auth/provider environment variables to spawned teammates. Based-on: #93
1 parent fea0b75 commit 63ac368

4 files changed

Lines changed: 132 additions & 34 deletions

File tree

src/openharness/config/settings.py

Lines changed: 53 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,30 @@ def auth_source_uses_api_key(auth_source: str) -> bool:
370370
return auth_source.endswith("_api_key")
371371

372372

373+
def auth_source_env_var_candidates(auth_source: str) -> tuple[str, ...]:
374+
"""Return env vars to probe for an auth source in precedence order."""
375+
mapping = {
376+
"anthropic_api_key": ("OPENHARNESS_ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY"),
377+
"openai_api_key": ("OPENHARNESS_OPENAI_API_KEY", "OPENAI_API_KEY"),
378+
"dashscope_api_key": ("OPENHARNESS_DASHSCOPE_API_KEY", "DASHSCOPE_API_KEY"),
379+
"moonshot_api_key": ("OPENHARNESS_MOONSHOT_API_KEY", "MOONSHOT_API_KEY"),
380+
"gemini_api_key": ("OPENHARNESS_GEMINI_API_KEY", "GEMINI_API_KEY"),
381+
"minimax_api_key": ("OPENHARNESS_MINIMAX_API_KEY", "MINIMAX_API_KEY"),
382+
"nvidia_api_key": ("OPENHARNESS_NVIDIA_API_KEY", "NVIDIA_API_KEY"),
383+
"modelscope_api_key": ("OPENHARNESS_MODELSCOPE_API_KEY", "MODELSCOPE_API_KEY"),
384+
}
385+
return mapping.get(auth_source, ())
386+
387+
388+
def resolve_auth_env_value(auth_source: str) -> tuple[str, str] | None:
389+
"""Return the first configured env var/value pair for an auth source."""
390+
for env_var in auth_source_env_var_candidates(auth_source):
391+
env_value = os.environ.get(env_var, "")
392+
if env_value:
393+
return env_var, env_value
394+
return None
395+
396+
373397
def credential_storage_provider_name(profile_name: str, profile: ProviderProfile) -> str:
374398
"""Return the storage namespace used for this profile's credential.
375399
@@ -712,19 +736,15 @@ def resolve_api_key(self) -> str:
712736
if self.api_key:
713737
return self.api_key
714738

715-
env_key = os.environ.get("ANTHROPIC_API_KEY", "")
716-
if env_key:
717-
return env_key
718-
719-
# Also check OPENAI_API_KEY for openai-format providers
720-
openai_key = os.environ.get("OPENAI_API_KEY", "")
721-
if openai_key:
722-
return openai_key
739+
env_resolved = resolve_auth_env_value(profile.auth_source)
740+
if env_resolved:
741+
_, env_value = env_resolved
742+
return env_value
723743

724744
raise ValueError(
725-
"No API key found. Set ANTHROPIC_API_KEY (or OPENAI_API_KEY for openai-format "
726-
"providers) environment variable, or configure api_key in "
727-
"~/.openharness/settings.json"
745+
"No API key found. Set an OPENHARNESS_* provider API key "
746+
"(preferred) or the matching native provider environment variable, "
747+
"or configure api_key in ~/.openharness/settings.json"
728748
)
729749

730750
def resolve_auth(self) -> ResolvedAuth:
@@ -800,25 +820,16 @@ def resolve_auth(self) -> ResolvedAuth:
800820

801821
storage_provider = credential_storage_provider_name(profile_name, profile)
802822

803-
env_var = {
804-
"anthropic_api_key": "ANTHROPIC_API_KEY",
805-
"openai_api_key": "OPENAI_API_KEY",
806-
"dashscope_api_key": "DASHSCOPE_API_KEY",
807-
"moonshot_api_key": "MOONSHOT_API_KEY",
808-
"minimax_api_key": "MINIMAX_API_KEY",
809-
"nvidia_api_key": "NVIDIA_API_KEY",
810-
"modelscope_api_key": "MODELSCOPE_API_KEY",
811-
}.get(auth_source)
812-
if env_var:
813-
env_value = os.environ.get(env_var, "")
814-
if env_value:
815-
return ResolvedAuth(
816-
provider=provider or storage_provider,
817-
auth_kind="api_key",
818-
value=env_value,
819-
source=f"env:{env_var}",
820-
state="configured",
821-
)
823+
env_resolved = resolve_auth_env_value(auth_source)
824+
if env_resolved:
825+
env_var, env_value = env_resolved
826+
return ResolvedAuth(
827+
provider=provider or storage_provider,
828+
auth_kind="api_key",
829+
value=env_value,
830+
source=f"env:{env_var}",
831+
state="configured",
832+
)
822833

823834
explicit_key = "" if profile.credential_slot else self.api_key
824835
if explicit_key:
@@ -938,15 +949,23 @@ def _apply_env_overrides(settings: Settings) -> Settings:
938949
if auto_compact_threshold_tokens:
939950
updates["auto_compact_threshold_tokens"] = int(auto_compact_threshold_tokens)
940951

941-
api_key = os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("OPENAI_API_KEY")
942-
if api_key:
952+
provider = os.environ.get("OPENHARNESS_PROVIDER")
953+
api_format = os.environ.get("OPENHARNESS_API_FORMAT")
954+
env_auth_source = active_profile.auth_source
955+
if provider or api_format:
956+
env_auth_source = default_auth_source_for_provider(
957+
provider or active_profile.provider,
958+
api_format or active_profile.api_format,
959+
)
960+
961+
env_resolved = resolve_auth_env_value(env_auth_source)
962+
if env_resolved:
963+
_, api_key = env_resolved
943964
updates["api_key"] = api_key
944965

945-
api_format = os.environ.get("OPENHARNESS_API_FORMAT")
946966
if api_format:
947967
updates["api_format"] = api_format
948968

949-
provider = os.environ.get("OPENHARNESS_PROVIDER")
950969
if provider:
951970
updates["provider"] = provider
952971

src/openharness/swarm/spawn_utils.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,17 @@
6565
"OPENHARNESS_LOGS_DIR",
6666
"OPENHARNESS_PROFILE",
6767
"OPENHARNESS_API_FORMAT",
68+
"OPENHARNESS_PROVIDER",
6869
"OPENHARNESS_BASE_URL",
6970
"OPENHARNESS_MODEL",
71+
"OPENHARNESS_ANTHROPIC_API_KEY",
72+
"OPENHARNESS_OPENAI_API_KEY",
73+
"OPENHARNESS_DASHSCOPE_API_KEY",
74+
"OPENHARNESS_MOONSHOT_API_KEY",
75+
"OPENHARNESS_GEMINI_API_KEY",
76+
"OPENHARNESS_MINIMAX_API_KEY",
77+
"OPENHARNESS_NVIDIA_API_KEY",
78+
"OPENHARNESS_MODELSCOPE_API_KEY",
7079
"OPENAI_API_KEY",
7180
]
7281

tests/test_config/test_settings.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,16 +38,26 @@ def test_resolve_api_key_from_instance(self):
3838
assert s.resolve_api_key() == "sk-test-123"
3939

4040
def test_resolve_api_key_from_env(self, monkeypatch):
41+
monkeypatch.delenv("OPENHARNESS_ANTHROPIC_API_KEY", raising=False)
4142
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-env-456")
4243
s = Settings()
4344
assert s.resolve_api_key() == "sk-env-456"
4445

46+
def test_resolve_api_key_prefers_openharness_env(self, monkeypatch):
47+
monkeypatch.setenv("OPENHARNESS_ANTHROPIC_API_KEY", "sk-oh-456")
48+
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-env-456")
49+
s = Settings()
50+
assert s.resolve_api_key() == "sk-oh-456"
51+
4552
def test_resolve_api_key_instance_takes_precedence(self, monkeypatch):
53+
monkeypatch.delenv("OPENHARNESS_ANTHROPIC_API_KEY", raising=False)
4654
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-env-456")
4755
s = Settings(api_key="sk-instance-789")
4856
assert s.resolve_api_key() == "sk-instance-789"
4957

5058
def test_resolve_api_key_missing_raises(self, monkeypatch):
59+
monkeypatch.delenv("OPENHARNESS_ANTHROPIC_API_KEY", raising=False)
60+
monkeypatch.delenv("OPENHARNESS_OPENAI_API_KEY", raising=False)
5161
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
5262
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
5363
s = Settings()
@@ -78,6 +88,7 @@ def test_resolve_auth_prefers_env_over_flat_api_key_for_openai(self, monkeypatch
7888
"""When api_format=openai, resolve_auth() should use OPENAI_API_KEY
7989
from the environment rather than the flat api_key field which may
8090
contain an Anthropic key from settings.json."""
91+
monkeypatch.delenv("OPENHARNESS_OPENAI_API_KEY", raising=False)
8192
monkeypatch.setenv("OPENAI_API_KEY", "sk-openai-correct")
8293
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
8394
s = Settings(api_key="sk-ant-wrong-provider", api_format="openai")
@@ -86,9 +97,21 @@ def test_resolve_auth_prefers_env_over_flat_api_key_for_openai(self, monkeypatch
8697
assert auth.value == "sk-openai-correct"
8798
assert "OPENAI" in auth.source
8899

100+
def test_resolve_auth_prefers_openharness_env_for_openai(self, monkeypatch):
101+
monkeypatch.setenv("OPENHARNESS_OPENAI_API_KEY", "sk-oh-openai")
102+
monkeypatch.setenv("OPENAI_API_KEY", "sk-openai-correct")
103+
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
104+
s = Settings(api_key="sk-ant-wrong-provider", api_format="openai")
105+
s = s.sync_active_profile_from_flat_fields()
106+
auth = s.resolve_auth()
107+
assert auth.value == "sk-oh-openai"
108+
assert auth.source == "env:OPENHARNESS_OPENAI_API_KEY"
109+
89110
def test_resolve_auth_falls_back_to_flat_api_key(self, monkeypatch):
90111
"""When no provider-specific env var is set, resolve_auth() should
91112
still fall back to the flat api_key field."""
113+
monkeypatch.delenv("OPENHARNESS_ANTHROPIC_API_KEY", raising=False)
114+
monkeypatch.delenv("OPENHARNESS_OPENAI_API_KEY", raising=False)
92115
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
93116
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
94117
s = Settings(api_key="sk-fallback-key")
@@ -109,6 +132,32 @@ def test_env_overrides_picks_up_openai_base_url(self, tmp_path: Path, monkeypatc
109132
s = load_settings(path)
110133
assert s.base_url == "https://relay.example.com/v1"
111134

135+
def test_load_settings_uses_profile_specific_openharness_env_key(self, tmp_path: Path, monkeypatch):
136+
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-wrong")
137+
monkeypatch.setenv("OPENHARNESS_OPENAI_API_KEY", "sk-oh-openai")
138+
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
139+
path = tmp_path / "settings.json"
140+
path.write_text(
141+
Settings(active_profile="openai-compatible").model_dump_json(),
142+
encoding="utf-8",
143+
)
144+
s = load_settings(path)
145+
assert s.active_profile == "openai-compatible"
146+
assert s.api_key == "sk-oh-openai"
147+
148+
def test_load_settings_ignores_wrong_provider_native_env_key(self, tmp_path: Path, monkeypatch):
149+
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-wrong")
150+
monkeypatch.delenv("OPENHARNESS_OPENAI_API_KEY", raising=False)
151+
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
152+
path = tmp_path / "settings.json"
153+
path.write_text(
154+
Settings(active_profile="openai-compatible").model_dump_json(),
155+
encoding="utf-8",
156+
)
157+
s = load_settings(path)
158+
assert s.active_profile == "openai-compatible"
159+
assert s.api_key == ""
160+
112161
def test_env_overrides_pick_up_compact_threshold_settings(self, tmp_path: Path, monkeypatch):
113162
monkeypatch.setenv("OPENHARNESS_CONTEXT_WINDOW_TOKENS", "123456")
114163
monkeypatch.setenv("OPENHARNESS_AUTO_COMPACT_THRESHOLD_TOKENS", "120000")
@@ -131,6 +180,8 @@ def test_anthropic_base_url_takes_precedence_over_openai(self, tmp_path: Path, m
131180

132181
class TestLoadSaveSettings:
133182
def test_load_missing_file_returns_defaults(self, tmp_path: Path, monkeypatch):
183+
monkeypatch.delenv("OPENHARNESS_ANTHROPIC_API_KEY", raising=False)
184+
monkeypatch.delenv("OPENHARNESS_OPENAI_API_KEY", raising=False)
134185
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
135186
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
136187
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
@@ -143,6 +194,8 @@ def test_load_missing_file_returns_defaults(self, tmp_path: Path, monkeypatch):
143194
assert s == Settings().materialize_active_profile()
144195

145196
def test_load_existing_file(self, tmp_path: Path, monkeypatch):
197+
monkeypatch.delenv("OPENHARNESS_ANTHROPIC_API_KEY", raising=False)
198+
monkeypatch.delenv("OPENHARNESS_OPENAI_API_KEY", raising=False)
146199
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
147200
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
148201
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
@@ -158,6 +211,8 @@ def test_load_existing_file(self, tmp_path: Path, monkeypatch):
158211
assert s.api_key == "" # default preserved
159212

160213
def test_save_and_load_roundtrip(self, tmp_path: Path, monkeypatch):
214+
monkeypatch.delenv("OPENHARNESS_ANTHROPIC_API_KEY", raising=False)
215+
monkeypatch.delenv("OPENHARNESS_OPENAI_API_KEY", raising=False)
161216
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
162217
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
163218
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)

tests/test_swarm/test_spawn_utils.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,21 @@ def test_build_inherited_env_vars_forwards_openharness_config_dir(monkeypatch):
3737
assert env["OPENHARNESS_CONFIG_DIR"] == "/opt/data/.openharness"
3838

3939

40+
def test_build_inherited_env_vars_includes_openharness_auth_vars(monkeypatch):
41+
monkeypatch.setenv("OPENHARNESS_PROVIDER", "openai")
42+
monkeypatch.setenv("OPENHARNESS_BASE_URL", "https://relay.example.com/v1")
43+
monkeypatch.setenv("OPENHARNESS_OPENAI_API_KEY", "sk-oh-openai")
44+
monkeypatch.setenv("OPENHARNESS_ANTHROPIC_API_KEY", "sk-oh-anthropic")
45+
46+
env = build_inherited_env_vars()
47+
48+
assert env["OPENHARNESS_AGENT_TEAMS"] == "1"
49+
assert env["OPENHARNESS_PROVIDER"] == "openai"
50+
assert env["OPENHARNESS_BASE_URL"] == "https://relay.example.com/v1"
51+
assert env["OPENHARNESS_OPENAI_API_KEY"] == "sk-oh-openai"
52+
assert env["OPENHARNESS_ANTHROPIC_API_KEY"] == "sk-oh-anthropic"
53+
54+
4055
# ---------------------------------------------------------------------------
4156
# build_inherited_cli_flags – model handling
4257
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)