From ec66638bd98a350ba9013c4722a4c7f22ee68e01 Mon Sep 17 00:00:00 2001 From: knqiufan Date: Sat, 3 Oct 2026 11:52:04 +0800 Subject: [PATCH 1/2] fix: allow agents to inherit OAuth-backed host models Use the runtime credential check for the inherited model binding, including stored OAuth credentials and parent session overrides. Reuse the same inheritance decision in setup while preserving raw provider settings and keeping OAuth tokens outside rendered agent configs. Cover every shipped launcher, independent keys, missing credentials, provider aliases, custom token paths, and setup readiness. Co-authored-by: Codex --- agents/README.md | 7 + raven/agent/subagent/vendored_agents.py | 37 ++-- raven/cli/subagent_setup.py | 6 +- raven/config/product_render.py | 32 +++- raven/i18n/zh.py | 2 +- tests/test_cli_subagent_setup.py | 40 +++-- tests/test_config_product_render.py | 221 ++++++++++++++++++++++++ tests/test_provider_auth_method.py | 32 +--- tests/test_subagent_vendored_agents.py | 42 +++-- 9 files changed, 336 insertions(+), 83 deletions(-) diff --git a/agents/README.md b/agents/README.md index b14c31828..40e395aaa 100644 --- a/agents/README.md +++ b/agents/README.md @@ -6,6 +6,13 @@ Five shipped agents today: `raven-code`, `raven-design`, `raven-oncall`, -- the scaffold command that instantiates this whole shape into a fresh folder; `BUILDING.md` in this directory is the from-zero guide. +Agents that have no model key of their own inherit the host's model binding, +provider configuration, routing and reasoning effort. Inheritance uses the +same credential check as the host runtime, including stored OAuth sign-ins +such as OpenAI Codex. The child reads OAuth credentials from the host's +`RAVEN_HOME` (or the provider's configured token-directory override); tokens +stay in that credential store rather than the rendered agent config. + What one agent directory carries: - `run.py` -- a stdlib-only launcher: render the agent's config (secret diff --git a/raven/agent/subagent/vendored_agents.py b/raven/agent/subagent/vendored_agents.py index 68db78458..9211b66ff 100644 --- a/raven/agent/subagent/vendored_agents.py +++ b/raven/agent/subagent/vendored_agents.py @@ -384,34 +384,23 @@ def _folder_addresses_openrouter(folder: Path) -> bool: def host_can_lend_a_key() -> bool: """Whether ``inherit_llm`` in the launchers would find anything to inherit. - A folder with no key of its own is not stranded: each launcher reads the - host raven's ``config.json`` (through ``raven.config.product_render``) and - copies its whole provider block, so the common case needs no credential - anywhere near the tree. - - Mirrors ``inherit_llm``'s own test rather than asking ``providers.auth``, - and the difference is the whole point. ``inherit_llm`` accepts exactly one - shape -- a literal ``apiKey`` on some provider section. A host signed in - through OAuth is configured by auth's rule and has nothing to lend by the - launcher's, because those credentials live under ``~/.raven/oauth/``. - Asking auth here would advertise an agent that dies at the first dispatch. - - The same file the launcher reads (``$RAVEN_HOME`` or ``~/.raven``), for - the same reason: two readers of one credential that disagree would have - the roster offer what the launcher then refuses. + Read the same host file and use the launcher's own inheritance decision, + including OAuth credentials, so setup cannot offer a model the launcher + refuses or withhold one it accepts. Invalid host settings leave nothing + to inherit rather than preventing the setup wizard from opening. """ - from raven.contracts.path_policy import CONFIG_FILENAME - from raven.home import raven_home + from raven.config.product_render import host_config, inherit_llm - config = raven_home() / CONFIG_FILENAME - try: - raw = json.loads(config.read_text(encoding="utf-8")) - except (OSError, ValueError): + raw = host_config() + if not isinstance(raw, dict) or not isinstance(raw.get("providers", {}), dict): return False - providers = raw.get("providers") if isinstance(raw, dict) else None - if not isinstance(providers, dict): + agents = raw.get("agents") or {} + if not isinstance(agents, dict) or not isinstance(agents.get("defaults") or {}, dict): + return False + try: + return bool(inherit_llm({}, raw)) + except (OSError, TypeError, ValueError): return False - return any(isinstance(p, dict) and str(p.get("apiKey") or "").strip() for p in providers.values()) def _resolved_python() -> str: diff --git a/raven/cli/subagent_setup.py b/raven/cli/subagent_setup.py index 5d1dd7254..492bf69c9 100644 --- a/raven/cli/subagent_setup.py +++ b/raven/cli/subagent_setup.py @@ -343,7 +343,7 @@ def configure_subagents(*, non_interactive: bool = False, warnings: Optional[lis if not can_inherit: console.print( t( - " [dim]This raven has no provider key to lend (an OAuth sign-in is not one):" + " [dim]This raven has no usable model provider to inherit:" " an agent tuned for its own model needs a key of its own, and one that runs on" " this raven's LLM is not ready until a provider is configured.[/dim]" ) @@ -367,8 +367,8 @@ def configure_subagents(*, non_interactive: bool = False, warnings: Optional[lis # A folder that recommends no model of its own runs on this raven's # LLM and nothing else: there is no key to take (the launcher would # not read it) and no model to recommend. It is ready exactly when - # this raven has a key to lend; otherwise the launcher refuses to - # start, and a tick here would certify a product that cannot. + # this raven has usable model credentials; otherwise the launcher + # refuses to start, and a tick here would certify a product that cannot. if can_inherit: set_up += 1 console.print(f" [green]\u2713[/green] {t('ready')} {t("(runs on this raven's LLM)")}") diff --git a/raven/config/product_render.py b/raven/config/product_render.py index 2adb45454..85aa933ea 100644 --- a/raven/config/product_render.py +++ b/raven/config/product_render.py @@ -127,8 +127,9 @@ def inherit_llm(config: dict, host: dict) -> str: brains are reachable, which one is chosen, how a model name routes, and the host's reasoning effort; deliberately not the rest of ``agents.defaults``, which are the product's own operating limits. ``""`` - when the host has no provider key to lend, which the caller treats as a - refusal to launch. + when the inherited model binding has no usable credentials, which the + caller treats as a refusal to launch. Only the LLM settings are parsed for + that check; the original sections are copied so newer fields survive. ``RAVEN_PARENT_MODEL`` / ``RAVEN_PARENT_REASONING_EFFORT`` are honoured on this branch, the fork launchers' own riders: the trunk cli dispatcher @@ -139,13 +140,12 @@ def inherit_llm(config: dict, host: dict) -> str: per-turn form was a cli-lane property; ledgered, D3). On the own-key branch the riders are deliberately ignored, as they always were. """ + from raven.config.schema import Config + from raven.providers.auth import MissingCredentialsError + from raven.providers.factory import check_provider_credentials + providers = host.get("providers") or {} - if not any(isinstance(p, dict) and p.get("apiKey") for p in providers.values()): - return "" - for key in ("providers", "routing"): - if key in host: - config[key] = host[key] - defaults = config.setdefault("agents", {}).setdefault("defaults", {}) + defaults = dict((config.get("agents") or {}).get("defaults") or {}) host_defaults = (host.get("agents") or {}).get("defaults") or {} for key in ("provider", "model", "reasoningEffort"): if key in host_defaults: @@ -172,6 +172,22 @@ def inherit_llm(config: dict, host: dict) -> str: head = split_model_id(model)[0] defaults["provider"] = head if head in providers else host_defaults.get("provider", "") + inherited = Config.model_validate( + { + "providers": providers, + "agents": { + "defaults": {key: defaults[key] for key in ("provider", "model", "reasoningEffort") if key in defaults} + }, + } + ) + try: + check_provider_credentials(inherited) + except MissingCredentialsError: + return "" + for key in ("providers", "routing"): + if key in host: + config[key] = host[key] + config.setdefault("agents", {}).setdefault("defaults", {}).update(defaults) if parent_protocol := os.environ.get("RAVEN_PARENT_PROTOCOL", "").strip(): provider = providers.get(defaults.get("provider") or "") if isinstance(provider, dict) and parent_model: diff --git a/raven/i18n/zh.py b/raven/i18n/zh.py index 4f958b1fd..f1d1c026c 100644 --- a/raven/i18n/zh.py +++ b/raven/i18n/zh.py @@ -405,7 +405,7 @@ " [green]Built.[/green]": " [green]构建完成。[/green]", " [dim]No sub-agent tree in this installation. A release wheel carries one; reinstall from a release, or run from a source checkout.[/dim]": " [dim]本次安装没有子代理目录。发布版 wheel 自带子代理;可重装发布版,或改用源码检出运行。[/dim]", " [dim]No sub-agent folders found.[/dim]": " [dim]没有找到子代理目录。[/dim]", - " [dim]This raven has no provider key to lend (an OAuth sign-in is not one): an agent tuned for its own model needs a key of its own, and one that runs on this raven's LLM is not ready until a provider is configured.[/dim]": " [dim]本机 raven 没有可借出的 provider key(OAuth 登录不算):为自己的模型调优的 agent 需要自己的 key,使用本机 raven LLM 的 agent 在配置 provider 之前不会就绪。[/dim]", + " [dim]This raven has no usable model provider to inherit: an agent tuned for its own model needs a key of its own, and one that runs on this raven's LLM is not ready until a provider is configured.[/dim]": " [dim]本机 raven 没有可继承的可用模型服务:为自己的模型调优的 agent 需要自己的 key,使用本机 raven LLM 的 agent 在配置 provider 之前不会就绪。[/dim]", "Recommended: {a0} via OpenRouter (reusing this raven's OpenRouter key)": "推荐: {a0},经 OpenRouter(复用本机 raven 的 OpenRouter key)", "\n {set_up} sub-agent(s) ready.": "\n {set_up} 个子代理已就绪。", "Recommended: {a0} via OpenRouter (needs an OpenRouter key)": "推荐: {a0},经 OpenRouter(需要一个 OpenRouter key)", diff --git a/tests/test_cli_subagent_setup.py b/tests/test_cli_subagent_setup.py index d9b0c4653..d98e92f62 100644 --- a/tests/test_cli_subagent_setup.py +++ b/tests/test_cli_subagent_setup.py @@ -753,13 +753,8 @@ def test_host_openrouter_key_is_empty_when_no_provider_is_set_up( assert subagent_setup.host_openrouter_key() == "" -def test_an_oauth_host_is_not_offered_its_own_llm(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The launchers accept a literal key and nothing else. - - An OAuth sign-in leaves `providers` with no `apiKey`, so `inherit_llm` - returns "" and the run exits -- after the wizard has already said - "registered". Offering the option at all is the defect. - """ +def test_an_unconfigured_host_is_not_offered_its_own_llm(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Setup withholds inheritance when the launcher has no usable host model.""" _folder(tmp_path, "raven-code") monkeypatch.setattr(subagent_setup, "agents_root", lambda: tmp_path) monkeypatch.setattr(subagent_setup, "host_openrouter_key", lambda: "") @@ -781,13 +776,38 @@ def test_a_host_with_a_literal_key_keeps_the_option(tmp_path: Path, monkeypatch: assert scripted.offered[0] == ["own", "inherit", "skip"] -def test_an_oauth_sign_in_is_not_a_key_to_lend(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - # Mirrors `inherit_llm`, not `providers.auth`: auth calls this host - # configured, and the launcher still has nothing to inherit. +def test_an_unsigned_oauth_provider_is_not_lendable(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: _host_config(tmp_path, monkeypatch, {"openai_codex": {"models": []}}) assert subagent_setup.host_can_lend_a_key() is False +def test_a_signed_in_oauth_host_is_offered_its_own_llm(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + _host_config(tmp_path, monkeypatch, {"openai_codex": {}}) + home = tmp_path / ".raven" + (home / "config.json").write_text( + json.dumps( + { + "providers": {"openai_codex": {}}, + "agents": {"defaults": {"provider": "openai_codex", "model": "openai-codex/gpt-5.6-sol"}}, + } + ), + encoding="utf-8", + ) + token_dir = home / "oauth" / "chatgpt" + token_dir.mkdir(parents=True) + (token_dir / "auth.json").write_text(json.dumps({"refresh_token": "synthetic-token"}), encoding="utf-8") + _folder(tmp_path, "raven-code") + monkeypatch.setattr(subagent_setup, "agents_root", lambda: tmp_path) + monkeypatch.setattr(subagent_setup, "host_openrouter_key", lambda: "") + scripted = _ScriptedSelect([("Set up", "skip")]) + monkeypatch.setattr(onboard_commands, "_require_questionary", lambda: scripted) + + subagent_setup.configure_subagents(warnings=[]) + + assert subagent_setup.host_can_lend_a_key() is True + assert scripted.offered[0] == ["own", "inherit", "skip"] + + def test_a_literal_key_anywhere_is_a_key_to_lend(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: _host_config(tmp_path, monkeypatch, {"custom": {"apiKey": "sk-x", "apiBase": "http://10.0.0.9:3000/v1"}}) assert subagent_setup.host_can_lend_a_key() is True diff --git a/tests/test_config_product_render.py b/tests/test_config_product_render.py index d6feb253d..19f355157 100644 --- a/tests/test_config_product_render.py +++ b/tests/test_config_product_render.py @@ -76,6 +76,227 @@ def test_inherit_llm_declines_without_a_host_key(): assert render.inherit_llm({}, {"providers": {"open": {"baseUrl": "u"}}}) == "" +@pytest.fixture() +def oauth_host(tmp_path, monkeypatch): + home = tmp_path / "host" + token_dir = home / "oauth" / "chatgpt" + token_dir.mkdir(parents=True) + monkeypatch.setenv("RAVEN_HOME", str(home)) + monkeypatch.delenv("CHATGPT_TOKEN_DIR", raising=False) + monkeypatch.delenv("CHATGPT_AUTH_FILE", raising=False) + for name in ("MODEL", "PROVIDER", "PROTOCOL", "REASONING_EFFORT"): + monkeypatch.delenv(f"RAVEN_PARENT_{name}", raising=False) + token = token_dir / "auth.json" + token.write_text(json.dumps({"refresh_token": "synthetic-refresh-token"}), encoding="utf-8") + host = { + "providers": { + "openai_codex": { + "models": ["openai-codex/gpt-5.6-sol"], + "futureSetting": {"opaque": True}, + } + }, + "agents": { + "defaults": { + "provider": "openai_codex", + "model": "openai-codex/gpt-5.6-sol", + "reasoningEffort": "low", + "maxToolIterations": 40, + } + }, + "routing": {"rules": []}, + "tools": {"web": {"providers": {"serper": {"apiKey": "synthetic-search-key"}}}}, + } + return host, token + + +@pytest.mark.parametrize("name", ["openai_codex", "openaiCodex", "openai-codex"]) +@pytest.mark.parametrize( + "credentials", + [ + {"access_token": "synthetic-access-token"}, + {"refresh_token": "synthetic-refresh-token"}, + {"access_token": "expired-access-token", "refresh_token": "synthetic-refresh-token", "expires_at": 0}, + ], +) +def test_inherit_llm_accepts_stored_oauth_credentials(oauth_host, name, credentials): + host, token = oauth_host + token.write_text(json.dumps(credentials), encoding="utf-8") + host["providers"][name] = host["providers"].pop("openai_codex") + host["agents"]["defaults"]["provider"] = name + config = {"agents": {"defaults": {"maxToolIterations": 20}}} + + taken = render.inherit_llm(config, host) + + assert taken + assert config["providers"] == host["providers"] + assert config["routing"] == host["routing"] + assert config["agents"]["defaults"] == { + "provider": name, + "model": "openai-codex/gpt-5.6-sol", + "reasoningEffort": "low", + "maxToolIterations": 20, + } + assert "synthetic-refresh-token" not in json.dumps(config) + assert "synthetic-access-token" not in json.dumps(config) + + +@pytest.mark.parametrize("provider", ["openai_codex", "auto", None]) +def test_inherit_llm_accepts_oauth_without_an_explicit_provider_section(oauth_host, provider): + host, _ = oauth_host + host["providers"] = {} + if provider is None: + host["agents"]["defaults"].pop("provider") + else: + host["agents"]["defaults"]["provider"] = provider + + config = {} + assert render.inherit_llm(config, host) + assert config["agents"]["defaults"]["model"] == "openai-codex/gpt-5.6-sol" + + +@pytest.mark.parametrize("contents", [None, "{}", '{"device_code_requested_at": 1}', "invalid json"]) +@pytest.mark.parametrize("other_key", [False, True]) +def test_inherit_llm_declines_unusable_oauth_even_with_another_provider_key(oauth_host, contents, other_key): + host, token = oauth_host + if contents is None: + token.unlink() + else: + token.write_text(contents, encoding="utf-8") + if other_key: + host["providers"]["openrouter"] = {"apiKey": "synthetic-other-key"} + config = {"agents": {"defaults": {"maxToolIterations": 20}}} + + assert render.inherit_llm(config, host) == "" + assert config == {"agents": {"defaults": {"maxToolIterations": 20}}} + + +@pytest.mark.parametrize("parent_key", ["", "synthetic-parent-key"]) +def test_inherit_llm_checks_the_parent_binding_instead_of_the_host_default(oauth_host, monkeypatch, parent_key): + host, _ = oauth_host + host["providers"]["openrouter"] = {"apiKey": parent_key} + monkeypatch.setenv("RAVEN_PARENT_MODEL", "openrouter/openai/gpt-5.6-sol") + monkeypatch.setenv("RAVEN_PARENT_PROVIDER", "openrouter") + config = {} + + taken = render.inherit_llm(config, host) + + assert bool(taken) == bool(parent_key) + if parent_key: + assert config["agents"]["defaults"]["provider"] == "openrouter" + assert config["agents"]["defaults"]["model"] == "openrouter/openai/gpt-5.6-sol" + else: + assert config == {} + + +@pytest.mark.parametrize( + "provider, model, section", + [ + ("openrouter", "openrouter/openai/gpt-5.6-sol", {"apiKey": "synthetic-key"}), + ("openrouter", "openrouter/openai/gpt-5.6-sol", {"api_key": "synthetic-key"}), + ("openrouter", "openrouter/openai/gpt-5.6-sol", {"endpoints": [{"label": "primary", "apiKey": "k"}]}), + ("gemini", "gemini/gemini-2.5-pro", {"apiKeyList": ["synthetic-key"]}), + ("ollama_chat", "ollama_chat/llama3", {"apiBase": "http://localhost:11434"}), + ], +) +def test_inherit_llm_uses_runtime_credential_rules(provider, model, section): + host = { + "providers": {provider: section}, + "agents": {"defaults": {"provider": provider, "model": model}}, + } + config = {} + + assert render.inherit_llm(config, host) + assert config["providers"] == host["providers"] + assert config["agents"]["defaults"]["model"] == model + + +@pytest.fixture() +def oauth_launcher(oauth_host, monkeypatch, product): + import importlib.util + + host, _ = oauth_host + home = Path(os.environ["RAVEN_HOME"]) + (home / "config.json").write_text(json.dumps(host), encoding="utf-8") + path = Path(render.__file__).resolve().parents[2] / "agents" / product / "run.py" + spec = importlib.util.spec_from_file_location(f"oauth_{product.replace('-', '_')}", path) + launcher = importlib.util.module_from_spec(spec) + spec.loader.exec_module(launcher) + monkeypatch.setattr(launcher, "env_value", lambda name: None) + monkeypatch.setattr(launcher, "log", lambda message: None) + if product == "raven-ppt": + monkeypatch.setattr(launcher, "resolve_context_window", lambda *args: None) + + def capture_rendered(config, root, **_kwargs): + target = root / ".config.rendered.json" + target.write_text(json.dumps(config), encoding="utf-8") + return target + + monkeypatch.setattr(render, "write_rendered", capture_rendered) + return launcher + + +@pytest.mark.parametrize("product", ["raven-code", "raven-design", "raven-oncall", "raven-research", "raven-ppt"]) +@pytest.mark.parametrize("custom_token_dir", [False, True]) +def test_launchers_inherit_oauth_and_resolve_host_credentials( + oauth_host, oauth_launcher, tmp_path, monkeypatch, product, custom_token_dir +): + from raven.config.loader import load_config + from raven.providers.auth import credential_files + from raven.providers.factory import check_provider_credentials + + host, token = oauth_host + if custom_token_dir: + custom = tmp_path / "custom-auth" + custom.mkdir() + relocated = custom / "account.json" + token.replace(relocated) + token = relocated + monkeypatch.setenv("CHATGPT_TOKEN_DIR", str(custom)) + monkeypatch.setenv("CHATGPT_AUTH_FILE", "account.json") + home = Path(os.environ["RAVEN_HOME"]) + if product == "raven-code": + rendered = oauth_launcher.render_acp_config(oauth_launcher.DEFAULT_CONFIG) + else: + rendered = oauth_launcher.render_config(oauth_launcher.DEFAULT_CONFIG) + data = json.loads(rendered.read_text(encoding="utf-8")) + + assert data["providers"] == host["providers"] + assert data["routing"] == host["routing"] + assert data["agents"]["defaults"]["provider"] == "openai_codex" + assert data["agents"]["defaults"]["model"] == "openai-codex/gpt-5.6-sol" + assert data["agents"]["defaults"]["reasoningEffort"] == "low" + assert "synthetic-refresh-token" not in rendered.read_text(encoding="utf-8") + assert rendered.parent != home + loaded = load_config(rendered) + check_provider_credentials(loaded) + assert credential_files("openai_codex") == [token] + + +@pytest.mark.parametrize("product", ["raven-code", "raven-oncall", "raven-research", "raven-ppt"]) +def test_launchers_own_keys_do_not_require_host_oauth(oauth_host, oauth_launcher, monkeypatch, product): + from raven.config.loader import load_config + from raven.providers.factory import check_provider_credentials + + _, token = oauth_host + token.unlink() + own_key = oauth_launcher.REQUIRED_SECRETS[0] + monkeypatch.setattr(oauth_launcher, "env_value", lambda name: "synthetic-own-key" if name == own_key else None) + + def refuse_inheritance(*_args): + raise AssertionError("an own-key launcher must not consult the host model") + + monkeypatch.setattr(render, "inherit_llm", refuse_inheritance) + if product == "raven-code": + rendered = oauth_launcher.render_acp_config(oauth_launcher.DEFAULT_CONFIG) + else: + rendered = oauth_launcher.render_config(oauth_launcher.DEFAULT_CONFIG) + loaded = load_config(rendered) + + assert loaded.get_provider_name() != "openai_codex" + assert loaded.get_api_key() == "synthetic-own-key" + check_provider_credentials(loaded) + + def test_inherit_llm_honours_the_parent_riders_on_the_inheritance_branch(monkeypatch): """The fork launchers' riders, kept at the shared seat (G1): the cli dispatcher injects RAVEN_PARENT_MODEL / RAVEN_PARENT_REASONING_EFFORT per diff --git a/tests/test_provider_auth_method.py b/tests/test_provider_auth_method.py index 2ec651e99..8c5a34781 100644 --- a/tests/test_provider_auth_method.py +++ b/tests/test_provider_auth_method.py @@ -466,15 +466,6 @@ def test_only_the_auth_module_decides_configuredness_from_a_key() -> None: # and `has_credential` both ask the tools, which is where each family's # rule already lives, so this file cannot become a second opinion. "raven/agent/tools/capabilities.py", - # The launcher library deciding whether a host config, read as raw - # JSON, carries any provider key worth inheriting wholesale - # (inherit_llm). No verdict on a specific Raven provider is made: the - # block is copied as-is precisely because two providers spelled the - # same can be two different endpoints, and the empty answer refuses - # the product launch rather than ruling any provider unconfigured. - # Asking auth would mean parsing the host's file into a RavenConfig a - # launcher deliberately treats as opaque, possibly newer, JSON. - "raven/config/product_render.py", "raven/config/update_providers.py", "raven/providers/litellm_provider.py", "raven/providers/factory.py", @@ -523,24 +514,11 @@ def test_only_the_auth_module_decides_configuredness_from_a_key() -> None: "raven/agent/subagent/backends/openai_api.py", "raven/agent/subagent/probe.py", "raven/rpc/methods/subagents.py", - # Two reads, neither an opinion on whether a Raven provider is set up. - # One copies this raven's OpenRouter key into a sub-agent's own `.env`, so + # Copies this raven's OpenRouter key into a sub-agent's own `.env`, so # a user who configured one in step 1 is not asked for a second copy; the # verdict that the provider is usable comes from `_configured_providers` - # (which rules through auth) before that value is touched at all. The - # other mirrors `inherit_llm` in the launchers, which are stdlib-only - # scripts outside this package: they cannot import auth and accept only a - # literal key, so an OAuth host is configured by auth's rule and has - # nothing to lend by theirs. That question is "will inherit_llm return - # non-empty", and only inherit_llm's own rule answers it. + # (which rules through auth) before that value is touched at all. "raven/cli/subagent_setup.py", - # Where that same `inherit_llm` question moved to. The agent layer now - # asks it too, because it decides whether a discovered vendored agent - # reaches the roster at all -- and the roster must not offer one whose - # launcher will then find nothing to inherit. Same reasoning as above, - # same file the launcher itself reads: two readers of one credential that - # disagreed would advertise an agent that dies at its first dispatch. - "raven/agent/subagent/vendored_agents.py", # The skill hub's endpoint credential, read to store or forward it. "raven/config/update_skills.py", } @@ -573,10 +551,10 @@ def key_reads(tree: ast.AST) -> list[int]: return found offenders = sorted( - f"{path.relative_to(root.parent)}:{line}" + f"{path.relative_to(root.parent).as_posix()}:{line}" for path in root.rglob("*.py") - if str(path.relative_to(root.parent)) not in allowed - for line in key_reads(ast.parse(path.read_text())) + if path.relative_to(root.parent).as_posix() not in allowed + for line in key_reads(ast.parse(path.read_text(encoding="utf-8"))) ) assert not offenders, "decide configuredness through providers.auth.credential_status: " + ", ".join(offenders) diff --git a/tests/test_subagent_vendored_agents.py b/tests/test_subagent_vendored_agents.py index bf205811a..6c1422ca7 100644 --- a/tests/test_subagent_vendored_agents.py +++ b/tests/test_subagent_vendored_agents.py @@ -478,13 +478,7 @@ def test_discovery_order_holds_when_a_row_is_overridden(self, tree: Path) -> Non def test_the_lending_test_reads_the_file_the_launcher_reads(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """One credential, one answer. - - The launchers' ``inherit_llm`` accepts exactly one shape: a literal - ``apiKey`` on some provider section of the host's ``config.json``. Asking - ``providers.auth`` instead would call an OAuth-signed-in host lendable and - advertise an agent that dies at its first dispatch. - """ + """Setup reads the same host credentials as the launcher.""" monkeypatch.setenv("RAVEN_HOME", str(tmp_path)) assert va.host_can_lend_a_key() is False @@ -496,6 +490,24 @@ def test_the_lending_test_reads_the_file_the_launcher_reads(tmp_path: Path, monk assert va.host_can_lend_a_key() is True, "any provider section, not openrouter specifically" +@pytest.mark.parametrize("signed_in", [False, True]) +def test_the_lending_test_uses_stored_oauth_credentials(tmp_path, monkeypatch, signed_in): + monkeypatch.setenv("RAVEN_HOME", str(tmp_path)) + monkeypatch.delenv("CHATGPT_TOKEN_DIR", raising=False) + monkeypatch.delenv("CHATGPT_AUTH_FILE", raising=False) + host = { + "providers": {"openai_codex": {}, "openrouter": {"apiKey": "synthetic-other-key"}}, + "agents": {"defaults": {"provider": "openai_codex", "model": "openai-codex/gpt-5.6-sol"}}, + } + (tmp_path / "config.json").write_text(json.dumps(host), encoding="utf-8") + if signed_in: + token_dir = tmp_path / "oauth" / "chatgpt" + token_dir.mkdir(parents=True) + (token_dir / "auth.json").write_text(json.dumps({"refresh_token": "synthetic-token"}), encoding="utf-8") + + assert va.host_can_lend_a_key() is signed_in + + def test_the_interpreter_is_this_one_unless_the_environment_names_another() -> None: """``install.py``'s own default order, minus its ``--python`` flag. The launchers import raven, and this process's interpreter has it by @@ -736,11 +748,21 @@ def test_an_unparseable_host_config_is_nothing_to_lend( assert va.host_can_lend_a_key() is False - def test_a_host_config_that_is_not_an_object_is_nothing_to_lend( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + @pytest.mark.parametrize( + "host", + [ + [], + {"providers": "not-a-mapping"}, + {"agents": "not-a-mapping"}, + {"agents": {"defaults": "not-a-mapping"}}, + {"providers": {"openrouter": {"apiKey": 42}}}, + ], + ) + def test_an_invalid_host_config_is_nothing_to_lend( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, host: object ) -> None: monkeypatch.setenv("RAVEN_HOME", str(tmp_path)) - (tmp_path / "config.json").write_text('{"providers": "not-a-mapping"}', encoding="utf-8") + (tmp_path / "config.json").write_text(json.dumps(host), encoding="utf-8") assert va.host_can_lend_a_key() is False From a918fde73d5053c7a489f052ec43cadecec330b7 Mon Sep 17 00:00:00 2001 From: knqiufan Date: Sat, 3 Oct 2026 12:33:36 +0800 Subject: [PATCH 2/2] test: align design image fixtures with host model bindings Select the configured OpenRouter chat model in the custom image test so its image-credential assertions run with a usable inherited host binding. Add a regression proving that an unrelated provider key cannot authenticate the selected host model. Co-authored-by: Codex --- tests/test_agents_design_launcher.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_agents_design_launcher.py b/tests/test_agents_design_launcher.py index 489b0c347..15e2658d1 100644 --- a/tests/test_agents_design_launcher.py +++ b/tests/test_agents_design_launcher.py @@ -358,6 +358,12 @@ def test_no_host_llm_key_refuses_even_with_own_key(grounded, tmp_path): grounded.render_config(RUN_PY.parent / "config.json") +def test_unconfigured_host_model_refuses_even_with_another_provider_key(grounded, tmp_path): + _host_config(tmp_path, {"providers": {"openrouter": {"apiKey": "sk-host-or"}}}) + with pytest.raises(SystemExit, match="host Raven settings"): + grounded.render_config(RUN_PY.parent / "config.json") + + def test_optional_keys_fall_back_per_slot_to_the_host_config(grounded, tmp_path): _host_config( tmp_path, @@ -411,9 +417,12 @@ def test_image_inherits_custom_host_settings(grounded, tmp_path): { "tools": {"media": {"image": {"apiKey": "sk-foreign", "apiBase": "https://images.example/v1"}}}, "providers": {"openrouter": {"apiKey": "sk-host-or"}}, + "agents": {"defaults": {"provider": "openrouter", "model": "openrouter/openai/gpt-5.6-sol"}}, }, ) data = _render(grounded) + assert data["agents"]["defaults"]["provider"] == "openrouter" + assert data["agents"]["defaults"]["model"] == "openrouter/openai/gpt-5.6-sol" image = data["tools"]["media"]["image"] assert image["apiKey"] == "sk-foreign" assert image["apiBase"] == "https://images.example/v1"