Preflight was hard-failing the workspace boot when required env vars or legacy auth_token_files were missing, raising SystemExit(1) before main.py's PR #2756 try/except could mount the not-configured handler. Result: codex/openclaw workspaces launched without OPENAI_API_KEY were INVISIBLE — `/.well-known/agent-card.json` never returned 200, the bench timed out at 600s, canvas had no actionable signal. PR #2756 fixed half the puzzle (decouple agent-card from adapter.setup() failure); this fixes the other half (decouple from preflight failure). Caught by bench-provision-time run 25335853189 on 2026-05-04: codex and openclaw both timed_out at 609s while claude-code (whose default model needs no env) hit 86.7s on the same AMI. Hermes hit 147s because hermes config doesn't declare top-level required_env. After this change: - Missing required_env: WARN (operator sees it in boot logs); workspace proceeds to adapter.setup() which raises with the same env-name detail; PR #2756's try/except mounts the not-configured handler; /.well-known/agent-card.json serves 200; JSON-RPC POST / returns -32603 "agent not configured" with the env-name in `error.data`. - Missing auth_token_file (legacy path): same treatment. - Other preflight failures (runtime adapter not installable, invalid A2A port) STAY as fails — those are structural, the workspace truly can't run. Updated 4 existing tests that asserted `report.ok is False` on required_env / auth_token misses to assert `report.ok is True` and check `report.warnings` instead. All 31 preflight tests pass; full suite 1664 pass + 1 unrelated flake on staging. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
720 lines
26 KiB
Python
720 lines
26 KiB
Python
"""Tests for preflight.py — workspace startup checks."""
|
|
import sys
|
|
import types
|
|
|
|
import pytest
|
|
|
|
from config import A2AConfig, RuntimeConfig, WorkspaceConfig
|
|
from preflight import run_preflight, render_preflight_report, PreflightIssue, PreflightReport
|
|
|
|
|
|
def make_config(**overrides):
|
|
"""Build a minimal workspace config for preflight tests."""
|
|
base = WorkspaceConfig(
|
|
name="Test Workspace",
|
|
runtime="langgraph",
|
|
runtime_config=RuntimeConfig(),
|
|
skills=[],
|
|
prompt_files=[],
|
|
a2a=A2AConfig(port=8000),
|
|
)
|
|
for key, value in overrides.items():
|
|
setattr(base, key, value)
|
|
return base
|
|
|
|
|
|
_UNSET = object()
|
|
|
|
|
|
def install_fake_adapter(monkeypatch, name: str = "langgraph", *, raise_on_name: bool = False, no_class: bool = False, name_returns=_UNSET):
|
|
"""Install a fake adapter module + ADAPTER_MODULE env var so the
|
|
runtime-discovery path in preflight finds it.
|
|
|
|
Args:
|
|
name: what Adapter.name() returns (default "langgraph" so the
|
|
base config's runtime field passes the equality check).
|
|
raise_on_name: if True, Adapter.name() raises (tests the catch path).
|
|
no_class: if True, the module imports but exports no Adapter symbol.
|
|
name_returns: override the literal value name() returns. Defaults
|
|
to a sentinel so that None is a passable test value
|
|
(else `if name_returns is not None` would skip the
|
|
None branch — exactly the bug this sentinel avoids).
|
|
"""
|
|
# Each call uses a unique module name so monkeypatch's sys.modules
|
|
# restoration doesn't accidentally reuse a prior test's fake when
|
|
# the same `name` is requested twice in one test session.
|
|
module_name = f"_fake_adapter_{name.replace('-', '_')}_{id(monkeypatch)}"
|
|
fake_mod = types.ModuleType(module_name)
|
|
|
|
if not no_class:
|
|
if raise_on_name:
|
|
class _Adapter:
|
|
@staticmethod
|
|
def name():
|
|
raise RuntimeError("boom")
|
|
elif name_returns is not _UNSET:
|
|
class _Adapter:
|
|
@staticmethod
|
|
def name():
|
|
return name_returns
|
|
else:
|
|
class _Adapter:
|
|
@staticmethod
|
|
def name():
|
|
return name
|
|
fake_mod.Adapter = _Adapter
|
|
|
|
monkeypatch.setitem(sys.modules, module_name, fake_mod)
|
|
monkeypatch.setenv("ADAPTER_MODULE", module_name)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _default_langgraph_adapter(monkeypatch, request):
|
|
"""Pre-install a langgraph adapter so existing tests that build a
|
|
default WorkspaceConfig (runtime="langgraph") pass the discovery
|
|
check without each test having to set ADAPTER_MODULE manually.
|
|
|
|
Tests that need to assert a specific failure mode (no adapter, drift,
|
|
missing class, etc.) opt out via the `no_default_adapter` marker:
|
|
|
|
@pytest.mark.no_default_adapter
|
|
def test_…(monkeypatch):
|
|
...
|
|
"""
|
|
if "no_default_adapter" in request.keywords:
|
|
return
|
|
install_fake_adapter(monkeypatch, name="langgraph")
|
|
|
|
|
|
def test_run_preflight_with_matching_adapter_passes(tmp_path):
|
|
"""When ADAPTER_MODULE points to a module whose Adapter.name()
|
|
matches config.runtime, preflight passes cleanly. Default fixture
|
|
installs a langgraph adapter; the base config also says langgraph."""
|
|
(tmp_path / "system-prompt.md").write_text("Base prompt.")
|
|
(tmp_path / "skills").mkdir()
|
|
|
|
config = make_config(prompt_files=["system-prompt.md"], skills=[])
|
|
report = run_preflight(config, str(tmp_path))
|
|
|
|
assert report.ok is True
|
|
assert report.failures == []
|
|
assert report.warnings == []
|
|
|
|
|
|
def test_run_preflight_unsupported_runtime_warns_about_drift(tmp_path):
|
|
"""When the runtime requested is not what the installed adapter
|
|
reports, preflight returns the drift warning (not failure) — the
|
|
adapter wins in production. The PRIOR static-list behavior would
|
|
have hard-failed here, but the discovery-based check trusts the
|
|
adapter and surfaces the mismatch as actionable info."""
|
|
(tmp_path / "system-prompt.md").write_text("Base prompt.")
|
|
# Default fixture installs Adapter.name() == "langgraph"; flip the
|
|
# config to a different name so the drift warning fires.
|
|
config = make_config(runtime="not-a-runtime", prompt_files=["system-prompt.md"])
|
|
|
|
report = run_preflight(config, str(tmp_path))
|
|
|
|
assert report.ok is True # drift, not fatal
|
|
assert any(issue.title == "Runtime" and "Drift" in issue.detail for issue in report.warnings)
|
|
|
|
|
|
@pytest.mark.no_default_adapter
|
|
def test_run_preflight_no_adapter_module_fails(tmp_path, monkeypatch):
|
|
"""ADAPTER_MODULE unset → no adapter installed → preflight fails
|
|
with an operator-actionable message naming the env var."""
|
|
monkeypatch.delenv("ADAPTER_MODULE", raising=False)
|
|
(tmp_path / "system-prompt.md").write_text("Base prompt.")
|
|
config = make_config(prompt_files=["system-prompt.md"])
|
|
|
|
report = run_preflight(config, str(tmp_path))
|
|
|
|
assert report.ok is False
|
|
runtime_failures = [i for i in report.failures if i.title == "Runtime"]
|
|
assert len(runtime_failures) == 1
|
|
assert "ADAPTER_MODULE" in runtime_failures[0].detail
|
|
assert "unset" in runtime_failures[0].detail
|
|
|
|
|
|
@pytest.mark.no_default_adapter
|
|
def test_run_preflight_adapter_module_unimportable_fails(tmp_path, monkeypatch):
|
|
"""ADAPTER_MODULE set to a non-existent module → import error →
|
|
preflight fails with the underlying exception type + message."""
|
|
monkeypatch.setenv("ADAPTER_MODULE", "this_module_does_not_exist_for_test")
|
|
(tmp_path / "system-prompt.md").write_text("Base prompt.")
|
|
config = make_config(prompt_files=["system-prompt.md"])
|
|
|
|
report = run_preflight(config, str(tmp_path))
|
|
|
|
assert report.ok is False
|
|
assert any(
|
|
i.title == "Runtime" and "not importable" in i.detail
|
|
for i in report.failures
|
|
)
|
|
|
|
|
|
@pytest.mark.no_default_adapter
|
|
def test_run_preflight_adapter_module_missing_class_fails(tmp_path, monkeypatch):
|
|
"""Module imports but doesn't export `Adapter` → fail with the
|
|
convention reminder. Pin the convention so a future refactor
|
|
that renames the class doesn't silently bypass discovery."""
|
|
install_fake_adapter(monkeypatch, name="langgraph", no_class=True)
|
|
(tmp_path / "system-prompt.md").write_text("Base prompt.")
|
|
config = make_config(prompt_files=["system-prompt.md"])
|
|
|
|
report = run_preflight(config, str(tmp_path))
|
|
|
|
assert report.ok is False
|
|
assert any(
|
|
i.title == "Runtime" and "no `Adapter` class" in i.detail
|
|
for i in report.failures
|
|
)
|
|
|
|
|
|
@pytest.mark.no_default_adapter
|
|
def test_run_preflight_adapter_name_raises_fails(tmp_path, monkeypatch):
|
|
"""Adapter.name() throwing must be caught — the static method
|
|
must be side-effect-free per BaseAdapter contract."""
|
|
install_fake_adapter(monkeypatch, raise_on_name=True)
|
|
(tmp_path / "system-prompt.md").write_text("Base prompt.")
|
|
config = make_config(prompt_files=["system-prompt.md"])
|
|
|
|
report = run_preflight(config, str(tmp_path))
|
|
|
|
assert report.ok is False
|
|
assert any(
|
|
i.title == "Runtime" and "name() raised" in i.detail
|
|
for i in report.failures
|
|
)
|
|
|
|
|
|
@pytest.mark.no_default_adapter
|
|
def test_run_preflight_adapter_name_non_string_fails(tmp_path, monkeypatch):
|
|
"""Adapter.name() returning None / int / etc. must fail — the
|
|
runtime identifier is a string by contract and downstream code
|
|
assumes that (config matching, log lines, etc.). Use 42 (int) as
|
|
the returned value so the assertion is unambiguous; None would
|
|
also work but int is more obviously a contract violation."""
|
|
install_fake_adapter(monkeypatch, name_returns=42)
|
|
(tmp_path / "system-prompt.md").write_text("Base prompt.")
|
|
config = make_config(prompt_files=["system-prompt.md"])
|
|
|
|
report = run_preflight(config, str(tmp_path))
|
|
|
|
assert report.ok is False
|
|
assert any(
|
|
i.title == "Runtime" and "non-empty string" in i.detail
|
|
for i in report.failures
|
|
)
|
|
|
|
|
|
# ---------- required_env checks ----------
|
|
|
|
|
|
def test_required_env_present_passes(tmp_path, monkeypatch):
|
|
"""When all required_env vars are set, preflight passes."""
|
|
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "sk-test")
|
|
|
|
config = make_config(
|
|
runtime="claude-code",
|
|
runtime_config=RuntimeConfig(required_env=["CLAUDE_CODE_OAUTH_TOKEN"]),
|
|
)
|
|
|
|
report = run_preflight(config, str(tmp_path))
|
|
|
|
assert report.ok is True
|
|
assert not any(issue.title == "Required env" for issue in report.failures)
|
|
|
|
|
|
def test_required_env_missing_warns_does_not_fail(tmp_path, monkeypatch):
|
|
"""When a required_env var is missing, preflight WARNS but does not
|
|
fail the boot. Pairs with PR #2756 (molecule-core): the workspace
|
|
binds /.well-known/agent-card.json regardless of credentials and
|
|
routes JSON-RPC to a -32603 'agent not configured' handler. Hard
|
|
failing here would crash before the not-configured path even loads,
|
|
leaving the workspace invisible — that's the failure mode that bit
|
|
codex/openclaw bench 25335853189 on 2026-05-04 even after PR #2756."""
|
|
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
|
|
|
|
config = make_config(
|
|
runtime="claude-code",
|
|
runtime_config=RuntimeConfig(required_env=["CLAUDE_CODE_OAUTH_TOKEN"]),
|
|
)
|
|
|
|
report = run_preflight(config, str(tmp_path))
|
|
|
|
assert report.ok is True
|
|
assert any(
|
|
issue.title == "Required env" and "CLAUDE_CODE_OAUTH_TOKEN" in issue.detail
|
|
for issue in report.warnings
|
|
)
|
|
assert not any(
|
|
issue.title == "Required env" for issue in report.failures
|
|
)
|
|
|
|
|
|
def test_required_env_multiple_all_present_passes(tmp_path, monkeypatch):
|
|
"""Multiple required_env vars all present should pass."""
|
|
monkeypatch.setenv("API_KEY_A", "key-a")
|
|
monkeypatch.setenv("API_KEY_B", "key-b")
|
|
|
|
config = make_config(
|
|
runtime_config=RuntimeConfig(required_env=["API_KEY_A", "API_KEY_B"]),
|
|
)
|
|
|
|
report = run_preflight(config, str(tmp_path))
|
|
|
|
assert report.ok is True
|
|
|
|
|
|
def test_required_env_multiple_one_missing_warns(tmp_path, monkeypatch):
|
|
"""If any required_env var is missing, preflight warns with that var
|
|
named (and does NOT fail). The eventual setup() failure is what
|
|
actually surfaces to the user via the -32603 handler — preflight is
|
|
just a logging signal for operators inspecting boot logs."""
|
|
monkeypatch.setenv("API_KEY_A", "key-a")
|
|
monkeypatch.delenv("API_KEY_B", raising=False)
|
|
|
|
config = make_config(
|
|
runtime_config=RuntimeConfig(required_env=["API_KEY_A", "API_KEY_B"]),
|
|
)
|
|
|
|
report = run_preflight(config, str(tmp_path))
|
|
|
|
assert report.ok is True
|
|
assert any(
|
|
issue.title == "Required env" and "API_KEY_B" in issue.detail
|
|
for issue in report.warnings
|
|
)
|
|
|
|
|
|
def test_required_env_empty_list_passes(tmp_path):
|
|
"""Empty required_env means no env checks — always passes."""
|
|
config = make_config(
|
|
runtime_config=RuntimeConfig(required_env=[]),
|
|
)
|
|
|
|
report = run_preflight(config, str(tmp_path))
|
|
|
|
assert report.ok is True
|
|
|
|
|
|
def test_required_env_skipped_in_smoke_mode(tmp_path, monkeypatch):
|
|
"""MOLECULE_SMOKE_MODE=1 demotes Required-env failures to warnings.
|
|
|
|
Boot smoke (issue #2275) exercises executor.execute() against stub
|
|
deps and never hits the real provider, so missing auth env is not
|
|
a real blocker. Without this bypass, every adapter that introduces
|
|
a new auth env var (HERMES_API_KEY, OPENROUTER_API_KEY, etc.)
|
|
would silently break the publish-image gate until molecule-ci's
|
|
fake-env list catches up — the 2026-05-03 hermes outage. The
|
|
warning still surfaces in the report so unset env doesn't go
|
|
completely silent.
|
|
"""
|
|
monkeypatch.delenv("HERMES_API_KEY", raising=False)
|
|
monkeypatch.setenv("MOLECULE_SMOKE_MODE", "1")
|
|
|
|
config = make_config(
|
|
runtime_config=RuntimeConfig(required_env=["HERMES_API_KEY"]),
|
|
)
|
|
|
|
report = run_preflight(config, str(tmp_path))
|
|
|
|
assert report.ok is True
|
|
assert any(
|
|
issue.title == "Required env" and "HERMES_API_KEY" in issue.detail
|
|
for issue in report.warnings
|
|
), "smoke-mode bypass should still warn so unset env stays visible"
|
|
assert not any(
|
|
issue.title == "Required env" for issue in report.failures
|
|
)
|
|
|
|
|
|
def test_required_env_smoke_mode_off_still_warns(tmp_path, monkeypatch):
|
|
"""Sanity: smoke bypass is OFF when MOLECULE_SMOKE_MODE is unset, but
|
|
the warning still fires (and preflight no longer hard-fails — see
|
|
test_required_env_missing_warns_does_not_fail for the rationale)."""
|
|
monkeypatch.delenv("HERMES_API_KEY", raising=False)
|
|
monkeypatch.delenv("MOLECULE_SMOKE_MODE", raising=False)
|
|
|
|
config = make_config(
|
|
runtime_config=RuntimeConfig(required_env=["HERMES_API_KEY"]),
|
|
)
|
|
|
|
report = run_preflight(config, str(tmp_path))
|
|
|
|
assert report.ok is True
|
|
assert any(
|
|
issue.title == "Required env" and "HERMES_API_KEY" in issue.detail
|
|
for issue in report.warnings
|
|
)
|
|
assert not any(
|
|
issue.title == "Required env" for issue in report.failures
|
|
)
|
|
|
|
|
|
# ---------- Per-model required_env (models[] override) ----------
|
|
|
|
|
|
def test_per_model_required_env_wins_over_top_level(tmp_path, monkeypatch):
|
|
"""When `runtime_config.models[]` declares per-model `required_env` and
|
|
the picked `model` matches an entry id, the entry's required_env wins
|
|
over the top-level fallback. The 2026-05-02 MiniMax-on-claude-code bug:
|
|
user picks MiniMax + sets MINIMAX_API_KEY, top-level demands
|
|
CLAUDE_CODE_OAUTH_TOKEN — without this override path the workspace
|
|
crash-loops on a stale top-level requirement."""
|
|
monkeypatch.setenv("MINIMAX_API_KEY", "mx-test")
|
|
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
|
|
|
|
config = make_config(
|
|
runtime="claude-code",
|
|
runtime_config=RuntimeConfig(
|
|
model="MiniMax-M2.7",
|
|
required_env=["CLAUDE_CODE_OAUTH_TOKEN"], # top-level fallback
|
|
models=[
|
|
{"id": "sonnet", "required_env": ["CLAUDE_CODE_OAUTH_TOKEN"]},
|
|
{"id": "MiniMax-M2.7", "required_env": ["MINIMAX_API_KEY"]},
|
|
],
|
|
),
|
|
)
|
|
|
|
report = run_preflight(config, str(tmp_path))
|
|
|
|
assert report.ok is True
|
|
assert not any(issue.title == "Required env" for issue in report.failures)
|
|
|
|
|
|
def test_top_level_required_env_used_when_no_models_declared(tmp_path, monkeypatch):
|
|
"""No `models[]` field → preserve the existing top-level behavior. This
|
|
is the single-model template path — claude-code-default before it grew
|
|
a Model dropdown, codex-default today, etc."""
|
|
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
|
|
|
|
config = make_config(
|
|
runtime="claude-code",
|
|
runtime_config=RuntimeConfig(
|
|
model="sonnet",
|
|
required_env=["CLAUDE_CODE_OAUTH_TOKEN"],
|
|
models=[],
|
|
),
|
|
)
|
|
|
|
report = run_preflight(config, str(tmp_path))
|
|
|
|
# Missing required_env is now a warning (workspace boots in
|
|
# not-configured state); see test_required_env_missing_warns_does_not_fail.
|
|
assert report.ok is True
|
|
assert any(
|
|
issue.title == "Required env" and "CLAUDE_CODE_OAUTH_TOKEN" in issue.detail
|
|
for issue in report.warnings
|
|
)
|
|
|
|
|
|
def test_top_level_used_when_picked_model_not_in_models_list(tmp_path, monkeypatch):
|
|
"""`models[]` declared but the picked `model` isn't listed → fall back
|
|
to the top-level required_env. Defensive: protects against typos /
|
|
template drift / a CP override that names a model the template doesn't
|
|
enumerate. Never silently accept zero-auth in that case."""
|
|
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
|
|
|
|
config = make_config(
|
|
runtime="claude-code",
|
|
runtime_config=RuntimeConfig(
|
|
model="some-unknown-model",
|
|
required_env=["CLAUDE_CODE_OAUTH_TOKEN"],
|
|
models=[
|
|
{"id": "sonnet", "required_env": ["CLAUDE_CODE_OAUTH_TOKEN"]},
|
|
{"id": "MiniMax-M2.7", "required_env": ["MINIMAX_API_KEY"]},
|
|
],
|
|
),
|
|
)
|
|
|
|
report = run_preflight(config, str(tmp_path))
|
|
|
|
assert report.ok is True
|
|
assert any(
|
|
issue.title == "Required env" and "CLAUDE_CODE_OAUTH_TOKEN" in issue.detail
|
|
for issue in report.warnings
|
|
)
|
|
|
|
|
|
def test_per_model_match_is_case_insensitive(tmp_path, monkeypatch):
|
|
"""Match `entry["id"]` against `runtime_config.model` case-insensitively
|
|
— canvas surfaces `MiniMax-M2.7`, registries normalise to lowercase
|
|
`minimax-m2.7`, MODEL_PROVIDER env may carry either. The match must
|
|
not be brittle to that drift or templates ship preflight failures
|
|
on a working auth setup."""
|
|
monkeypatch.setenv("MINIMAX_API_KEY", "mx-test")
|
|
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
|
|
|
|
config = make_config(
|
|
runtime="claude-code",
|
|
runtime_config=RuntimeConfig(
|
|
model="minimax-m2.7", # lowercase
|
|
required_env=["CLAUDE_CODE_OAUTH_TOKEN"],
|
|
models=[
|
|
{"id": "MiniMax-M2.7", "required_env": ["MINIMAX_API_KEY"]}, # mixed case
|
|
],
|
|
),
|
|
)
|
|
|
|
report = run_preflight(config, str(tmp_path))
|
|
|
|
assert report.ok is True
|
|
assert not any(issue.title == "Required env" for issue in report.failures)
|
|
|
|
|
|
def test_per_model_match_with_no_required_env_key_falls_back_to_top_level(tmp_path, monkeypatch):
|
|
"""An entry that matches the picked model but has NO `required_env`
|
|
key at all falls back to the top-level list. Distinct from the
|
|
explicit-empty case below — many templates list a `name`/`description`
|
|
per model without enumerating env vars when the auth is identical
|
|
across the family, and we should not surprise them."""
|
|
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "sk-test")
|
|
|
|
config = make_config(
|
|
runtime="claude-code",
|
|
runtime_config=RuntimeConfig(
|
|
model="sonnet",
|
|
required_env=["CLAUDE_CODE_OAUTH_TOKEN"],
|
|
models=[
|
|
{"id": "sonnet", "name": "Claude Sonnet"}, # no required_env key
|
|
],
|
|
),
|
|
)
|
|
|
|
report = run_preflight(config, str(tmp_path))
|
|
|
|
assert report.ok is True
|
|
assert not any(issue.title == "Required env" for issue in report.failures)
|
|
|
|
|
|
def test_per_model_explicit_empty_required_env_means_no_auth(tmp_path, monkeypatch):
|
|
"""An entry with an explicit `required_env: []` means "this model
|
|
needs no auth" — common for local Ollama, Llamafile, or self-hosted
|
|
OpenAI-compat endpoints. This MUST short-circuit the top-level
|
|
fallback or the template author can't express a zero-auth model
|
|
without lying in the per-model list. Distinguished from the no-key
|
|
case via `"required_env" in entry` (key presence, not truthiness)."""
|
|
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
|
|
|
|
config = make_config(
|
|
runtime="claude-code",
|
|
runtime_config=RuntimeConfig(
|
|
model="local-llama",
|
|
# Top-level requires an auth token — but the picked model is
|
|
# a local one that genuinely needs none. Explicit-empty wins.
|
|
required_env=["CLAUDE_CODE_OAUTH_TOKEN"],
|
|
models=[
|
|
{"id": "sonnet", "required_env": ["CLAUDE_CODE_OAUTH_TOKEN"]},
|
|
{"id": "local-llama", "required_env": []}, # explicit zero-auth
|
|
],
|
|
),
|
|
)
|
|
|
|
report = run_preflight(config, str(tmp_path))
|
|
|
|
assert report.ok is True
|
|
assert not any(issue.title == "Required env" for issue in report.failures)
|
|
|
|
|
|
def test_per_model_required_env_null_treated_as_empty_no_auth(tmp_path, monkeypatch):
|
|
"""YAML `required_env: null` deserializes to None — the parser falls
|
|
through to `entry.get("required_env") or []`, so null behaves the
|
|
same as explicit `[]` (zero-auth). Pins the parser tolerance —
|
|
template authors who write `required_env:` without a value (common
|
|
YAML mistake) get the no-auth path, not a confusing TypeError."""
|
|
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
|
|
|
|
config = make_config(
|
|
runtime="claude-code",
|
|
runtime_config=RuntimeConfig(
|
|
model="local-llama",
|
|
required_env=["CLAUDE_CODE_OAUTH_TOKEN"],
|
|
models=[
|
|
{"id": "local-llama", "required_env": None}, # null in YAML
|
|
],
|
|
),
|
|
)
|
|
|
|
report = run_preflight(config, str(tmp_path))
|
|
|
|
assert report.ok is True
|
|
assert not any(issue.title == "Required env" for issue in report.failures)
|
|
|
|
|
|
# ---------- Legacy auth_token_file backward compat ----------
|
|
|
|
|
|
def test_legacy_auth_token_file_missing_no_env_warns(tmp_path, monkeypatch):
|
|
"""Legacy: missing auth_token_file with no env var emits a warning,
|
|
not a hard failure. Same reasoning as
|
|
test_required_env_missing_warns_does_not_fail — adapter.setup() is
|
|
the authoritative auth check, preflight just surfaces the issue
|
|
early in the boot log. The workspace still binds /agent-card and
|
|
routes to the not-configured -32603 handler."""
|
|
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
|
|
|
|
config = make_config(
|
|
runtime_config=RuntimeConfig(auth_token_file="secrets/token.txt"),
|
|
)
|
|
|
|
report = run_preflight(config, str(tmp_path))
|
|
|
|
assert report.ok is True
|
|
assert any(issue.title == "Auth token" for issue in report.warnings)
|
|
assert not any(issue.title == "Auth token" for issue in report.failures)
|
|
|
|
|
|
def test_legacy_auth_token_file_missing_but_auth_token_env_passes(tmp_path, monkeypatch):
|
|
"""Legacy: missing file but auth_token_env set should pass."""
|
|
monkeypatch.setenv("MY_AUTH_TOKEN", "fake-token")
|
|
|
|
config = make_config(
|
|
runtime_config=RuntimeConfig(
|
|
auth_token_file="secrets/token.txt",
|
|
auth_token_env="MY_AUTH_TOKEN",
|
|
),
|
|
)
|
|
|
|
report = run_preflight(config, str(tmp_path))
|
|
|
|
assert report.ok is True
|
|
|
|
|
|
def test_legacy_auth_token_file_missing_but_required_env_passes(tmp_path, monkeypatch):
|
|
"""Legacy: missing file but required_env satisfied should pass."""
|
|
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "sk-test")
|
|
|
|
config = make_config(
|
|
runtime="claude-code",
|
|
runtime_config=RuntimeConfig(
|
|
auth_token_file=".auth-token",
|
|
required_env=["CLAUDE_CODE_OAUTH_TOKEN"],
|
|
),
|
|
)
|
|
|
|
report = run_preflight(config, str(tmp_path))
|
|
|
|
assert report.ok is True
|
|
|
|
|
|
def test_legacy_auth_token_file_exists_passes(tmp_path):
|
|
"""Legacy: when the file exists, it passes with no auth warnings."""
|
|
(tmp_path / ".auth-token").write_text("sk-from-file")
|
|
(tmp_path / "system-prompt.md").write_text("prompt")
|
|
|
|
config = make_config(
|
|
runtime_config=RuntimeConfig(auth_token_file=".auth-token"),
|
|
prompt_files=["system-prompt.md"],
|
|
)
|
|
|
|
report = run_preflight(config, str(tmp_path))
|
|
|
|
assert report.ok is True
|
|
assert not any(issue.title == "Auth token" for issue in report.warnings)
|
|
assert report.failures == []
|
|
|
|
|
|
# ---------- Other checks ----------
|
|
|
|
|
|
def test_run_preflight_missing_prompts_and_skills_warn(tmp_path):
|
|
"""Missing prompt files and skills should warn, not fail."""
|
|
config = make_config(
|
|
prompt_files=["missing-prompt.md"],
|
|
skills=["missing-skill"],
|
|
)
|
|
|
|
report = run_preflight(config, str(tmp_path))
|
|
|
|
assert report.ok is True
|
|
assert report.failures == []
|
|
assert any(issue.title == "Prompt file" for issue in report.warnings)
|
|
assert any(issue.title == "Skill" for issue in report.warnings)
|
|
|
|
|
|
def test_run_preflight_valid_config_passes(tmp_path):
|
|
"""A fully populated config should pass with no issues."""
|
|
(tmp_path / "system-prompt.md").write_text("Base prompt.")
|
|
skill_dir = tmp_path / "skills" / "writing"
|
|
skill_dir.mkdir(parents=True)
|
|
(skill_dir / "SKILL.md").write_text("Write clearly.")
|
|
|
|
config = make_config(
|
|
prompt_files=["system-prompt.md"],
|
|
skills=["writing"],
|
|
runtime_config=RuntimeConfig(),
|
|
)
|
|
|
|
report = run_preflight(config, str(tmp_path))
|
|
|
|
assert report.ok is True
|
|
assert report.failures == []
|
|
assert report.warnings == []
|
|
|
|
|
|
def test_run_preflight_invalid_port_fails(tmp_path):
|
|
"""A port value of 0 is out of range and should trigger a failure."""
|
|
config = make_config(
|
|
a2a=A2AConfig(port=0),
|
|
)
|
|
|
|
report = run_preflight(config, str(tmp_path))
|
|
|
|
assert report.ok is False
|
|
assert any(issue.title == "A2A port" for issue in report.failures)
|
|
|
|
|
|
def test_render_preflight_report_with_failures(capsys):
|
|
"""render_preflight_report prints [FAIL] lines with fix hints."""
|
|
report = PreflightReport(
|
|
failures=[
|
|
PreflightIssue(
|
|
severity="fail",
|
|
title="Runtime",
|
|
detail="Unsupported runtime 'bogus'",
|
|
fix="Choose a supported runtime.",
|
|
)
|
|
],
|
|
warnings=[],
|
|
)
|
|
|
|
render_preflight_report(report)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "Preflight checks:" in captured.out
|
|
assert "[FAIL] Runtime: Unsupported runtime 'bogus'" in captured.out
|
|
assert "Fix: Choose a supported runtime." in captured.out
|
|
|
|
|
|
def test_render_preflight_report_with_warnings(capsys):
|
|
"""render_preflight_report prints [WARN] lines with fix hints."""
|
|
report = PreflightReport(
|
|
failures=[],
|
|
warnings=[
|
|
PreflightIssue(
|
|
severity="warn",
|
|
title="Prompt file",
|
|
detail="Missing prompt file: missing.md",
|
|
fix="Add the file or remove it from prompt_files.",
|
|
)
|
|
],
|
|
)
|
|
|
|
render_preflight_report(report)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "Preflight checks:" in captured.out
|
|
assert "[WARN] Prompt file: Missing prompt file: missing.md" in captured.out
|
|
assert "Fix: Add the file or remove it from prompt_files." in captured.out
|
|
|
|
|
|
def test_render_preflight_report_no_output_when_clean(capsys):
|
|
"""render_preflight_report prints nothing when there are no issues."""
|
|
report = PreflightReport(failures=[], warnings=[])
|
|
|
|
render_preflight_report(report)
|
|
|
|
captured = capsys.readouterr()
|
|
assert captured.out == ""
|