Files
2026-07-13 13:12:00 +08:00

3590 lines
125 KiB
Python

"""Tests for omnigent.spec.parser."""
from __future__ import annotations
from pathlib import Path
import pytest
import yaml
from omnigent.errors import OmnigentError
from omnigent.spec.parser import discover_host_skills, parse
from omnigent.spec.types import ApiKeyAuth, DatabricksAuth, ProviderAuth, SharePolicy
@pytest.fixture()
def agent_dir(tmp_path: Path) -> Path:
"""Create a minimal valid agent image directory."""
config = {"spec_version": 1, "name": "test-agent"}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
return tmp_path
def test_parse_minimal(agent_dir: Path) -> None:
spec = parse(agent_dir)
assert spec.spec_version == 1
assert spec.name == "test-agent"
assert spec.description is None
assert spec.llm is None
assert spec.interaction.conversational is True
assert spec.interaction.modalities.input == ["text"]
assert spec.interaction.modalities.output == ["text"]
assert spec.tools.agents == []
assert spec.params == {}
assert spec.instructions is None
assert spec.skills == []
assert spec.mcp_servers == []
assert spec.local_tools == []
assert spec.sub_agents == []
def test_parse_missing_config_yaml(tmp_path: Path) -> None:
with pytest.raises(FileNotFoundError, match=r"config.yaml not found"):
parse(tmp_path)
def test_parse_non_mapping_config(tmp_path: Path) -> None:
(tmp_path / "config.yaml").write_text("- just a list")
with pytest.raises(OmnigentError, match=r"must be a YAML mapping"):
parse(tmp_path)
def test_parse_missing_spec_version(tmp_path: Path) -> None:
(tmp_path / "config.yaml").write_text(yaml.dump({"name": "no-version"}))
with pytest.raises(OmnigentError, match=r"missing required field: spec_version"):
parse(tmp_path)
def test_parse_full_config(tmp_path: Path) -> None:
config = {
"spec_version": 1,
"name": "full-agent",
"description": "A fully configured agent.",
"llm": {
"model": "openai/gpt-5.4",
"max_completion_tokens": 4096,
"reasoning_effort": "medium",
},
"interaction": {
"conversational": True,
"modalities": {
"input": ["text", "image", "file"],
"output": ["text"],
},
},
"tools": {"agents": ["researcher", "critic"]},
"params": {"max_results": 10, "prefer_recent": True},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
assert spec.name == "full-agent"
assert spec.description == "A fully configured agent."
assert spec.llm is not None
assert spec.llm.model == "openai/gpt-5.4"
# executor.model is the canonical source — verify consolidation
assert spec.executor.model == "openai/gpt-5.4"
assert spec.llm.extra == {
"max_completion_tokens": 4096,
"reasoning_effort": "medium",
}
assert spec.interaction.conversational is True
assert spec.interaction.modalities.input == ["text", "image", "file"]
assert spec.interaction.modalities.output == ["text"]
assert spec.tools.agents == ["researcher", "critic"]
assert spec.params == {"max_results": 10, "prefer_recent": True}
def test_parse_llm_missing_model(tmp_path: Path) -> None:
config = {"spec_version": 1, "llm": {"max_completion_tokens": 100}}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
with pytest.raises(OmnigentError, match=r"missing required field: model"):
parse(tmp_path)
def test_parse_llm_arbitrary_extra_keys(tmp_path: Path) -> None:
"""All non-model keys in the llm block are collected into extra."""
config = {
"spec_version": 1,
"llm": {
"model": "anthropic/claude-sonnet-4-20250514",
"temperature": 0.7,
"top_p": 0.9,
"max_tokens": 2048,
"stop": ["\n\n"],
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
assert spec.llm is not None
assert spec.llm.model == "anthropic/claude-sonnet-4-20250514"
assert spec.llm.extra == {
"temperature": 0.7,
"top_p": 0.9,
"max_tokens": 2048,
"stop": ["\n\n"],
}
def test_parse_llm_model_only(tmp_path: Path) -> None:
"""LLM block with only model has empty extra and no connection."""
config = {"spec_version": 1, "llm": {"model": "openai/gpt-4o"}}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
assert spec.llm is not None
assert spec.llm.model == "openai/gpt-4o"
assert spec.llm.extra == {}
assert spec.llm.connection is None
def test_parse_llm_connection_block(tmp_path: Path) -> None:
"""The connection sub-block is parsed into LLMConfig.connection."""
config = {
"spec_version": 1,
"llm": {
"model": "databricks/databricks-gpt-5-4",
"temperature": 0.5,
"connection": {
"api_key": "dapi_test_key",
"base_url": "https://my-workspace.databricks.com/serving-endpoints",
},
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
assert spec.llm is not None
assert spec.llm.model == "databricks/databricks-gpt-5-4"
assert spec.llm.extra == {"temperature": 0.5}
assert spec.llm.connection == {
"api_key": "dapi_test_key",
"base_url": "https://my-workspace.databricks.com/serving-endpoints",
}
def test_parse_llm_connection_expands_env_vars(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``${VAR}`` references in connection values are expanded."""
monkeypatch.setenv("MY_API_KEY", "sk-secret-123")
config = {
"spec_version": 1,
"llm": {
"model": "openai/gpt-5.4",
"connection": {"api_key": "${MY_API_KEY}"},
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
assert spec.llm is not None
assert spec.llm.connection == {"api_key": "sk-secret-123"}
def test_parse_llm_connection_unresolved_var_raises(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Unresolved ``${VAR}`` in LLM connection raises ValueError.
:param tmp_path: Temporary directory for config files.
:param monkeypatch: Pytest monkeypatch for env vars.
"""
monkeypatch.delenv("MY_API_KEY", raising=False)
config = {
"spec_version": 1,
"llm": {
"model": "openai/gpt-4o",
"connection": {"api_key": "${MY_API_KEY}"},
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
with pytest.raises(OmnigentError, match=r"Unresolved environment variable"):
parse(tmp_path)
def test_parse_inline_mcp_tools_whitelist(tmp_path: Path) -> None:
"""A per-server ``tools:`` whitelist on an inline MCP tool propagates to
``MCPServerConfig.tools`` (regression: it was silently dropped, so the
documented allow-list was a no-op and all tools were exposed)."""
config = {
"spec_version": 1,
"tools": {
"github": {
"type": "mcp",
"command": "npx",
"args": ["-y", "server-github"],
"tools": ["search_issues", "get_pull_request"],
},
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
cfg = next(m for m in spec.mcp_servers if m.name == "github")
assert cfg.tools == ["search_issues", "get_pull_request"]
def test_parse_inline_mcp_tools_absent_is_none(tmp_path: Path) -> None:
"""Omitting ``tools:`` leaves the allow-list as ``None`` (expose all)."""
config = {
"spec_version": 1,
"tools": {"github": {"type": "mcp", "command": "npx", "args": []}},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
cfg = next(m for m in parse(tmp_path).mcp_servers if m.name == "github")
assert cfg.tools is None
def test_parse_inline_mcp_tools_non_list_raises(tmp_path: Path) -> None:
"""A non-list ``tools:`` value is a clear error, not a silent type bug."""
config = {
"spec_version": 1,
"tools": {"github": {"type": "mcp", "command": "npx", "tools": "search_issues"}},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
with pytest.raises(OmnigentError, match=r"'tools' must be a list"):
parse(tmp_path)
def test_parse_expand_env_false_keeps_var_references(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
``expand_env=False`` keeps ``${VAR}`` references as literal strings.
Used during scaffolding/validation (e.g. ``omnigent create``) where
env vars may not yet be set in the current process.
"""
monkeypatch.delenv("MY_API_KEY", raising=False)
config = {
"spec_version": 1,
"llm": {
"model": "openai/gpt-4o",
"connection": {"api_key": "${MY_API_KEY}"},
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path, expand_env=False)
assert spec.llm is not None
assert spec.llm.connection == {"api_key": "${MY_API_KEY}"}
def test_parse_builtin_tool_config_expands_env_vars(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``${VAR}`` references in builtin tool config values are expanded."""
monkeypatch.setenv("PERPLEXITY_API_KEY", "pplx-redacted-test-key")
config = {
"spec_version": 1,
"tools": {
"builtins": [
{
"name": "web_search",
"search_provider": "perplexity",
"api_key": "${PERPLEXITY_API_KEY}",
},
],
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
builtin = spec.tools.builtins[0]
assert builtin.name == "web_search"
assert builtin.config == {
"search_provider": "perplexity",
"api_key": "pplx-redacted-test-key",
}
def test_parse_builtin_tool_config_expand_env_false_keeps_literals(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``expand_env=False`` keeps builtin tool ``${VAR}`` config literal."""
monkeypatch.delenv("PERPLEXITY_API_KEY", raising=False)
config = {
"spec_version": 1,
"tools": {
"builtins": [
{
"name": "web_search",
"search_provider": "perplexity",
"api_key": "${PERPLEXITY_API_KEY}",
},
],
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path, expand_env=False)
assert spec.tools.builtins[0].config == {
"search_provider": "perplexity",
"api_key": "${PERPLEXITY_API_KEY}",
}
def test_parse_builtin_tool_config_unresolved_var_raises(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Unresolved ``${VAR}`` in builtin tool config raises clearly."""
monkeypatch.delenv("PERPLEXITY_API_KEY", raising=False)
config = {
"spec_version": 1,
"tools": {
"builtins": [
{"name": "web_search", "api_key": "${PERPLEXITY_API_KEY}"},
],
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
with pytest.raises(OmnigentError, match=r"Unresolved environment variable"):
parse(tmp_path)
def test_parse_instructions_multiline_inline(tmp_path: Path) -> None:
"""Multiline inline instructions are not treated as file paths."""
config = {
"spec_version": 1,
"instructions": "Line one.\nLine two.\nLine three.",
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
assert spec.instructions == "Line one.\nLine two.\nLine three."
def test_parse_agents_md_fallback(agent_dir: Path) -> None:
"""No instructions key in config -> falls back to AGENTS.md."""
(agent_dir / "AGENTS.md").write_text("You are a helpful research assistant.")
spec = parse(agent_dir)
assert spec.instructions == "You are a helpful research assistant."
def test_parse_instructions_inline(tmp_path: Path) -> None:
"""instructions key with inline text (not a file path)."""
config = {"spec_version": 1, "instructions": "Be concise and helpful."}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
assert spec.instructions == "Be concise and helpful."
def test_parse_instructions_file_reference(agent_dir: Path) -> None:
"""instructions key pointing to an existing file."""
(agent_dir / "SYSTEM.md").write_text("Custom system prompt from file.")
config = {"spec_version": 1, "name": "test-agent", "instructions": "SYSTEM.md"}
(agent_dir / "config.yaml").write_text(yaml.dump(config))
spec = parse(agent_dir)
assert spec.instructions == "Custom system prompt from file."
def test_parse_instructions_rejects_path_traversal(tmp_path: Path) -> None:
"""An ``instructions`` value escaping the bundle is treated as literal text.
A crafted/uploaded bundle could set ``instructions: ../secret.txt`` to make
the runner read a file outside the bundle root and fold it into the agent's
system prompt (W7 spec-injection). The parser must NOT read an out-of-root
target — it falls back to treating the value as inline text, so the file's
contents never enter the spec. If this regresses, ``spec.instructions``
would contain the secret file's body.
"""
secret = tmp_path / "secret.txt"
secret.write_text("TOP SECRET RUNNER FILE")
bundle = tmp_path / "bundle"
bundle.mkdir()
config = {"spec_version": 1, "name": "evil", "instructions": "../secret.txt"}
(bundle / "config.yaml").write_text(yaml.dump(config))
spec = parse(bundle)
# The out-of-root target is never read — its contents must not leak.
assert "TOP SECRET" not in (spec.instructions or "")
# Falls back to the literal value (the existing "missing file → inline" path).
assert spec.instructions == "../secret.txt"
def test_parse_instructions_overrides_agents_md(agent_dir: Path) -> None:
"""Explicit instructions key takes precedence over AGENTS.md."""
(agent_dir / "AGENTS.md").write_text("Fallback instructions.")
config = {"spec_version": 1, "name": "test-agent", "instructions": "Inline wins."}
(agent_dir / "config.yaml").write_text(yaml.dump(config))
spec = parse(agent_dir)
assert spec.instructions == "Inline wins."
def test_parse_instructions_file_overrides_agents_md(agent_dir: Path) -> None:
"""instructions pointing to a file takes precedence over AGENTS.md."""
(agent_dir / "AGENTS.md").write_text("Fallback instructions.")
(agent_dir / "CUSTOM.md").write_text("Custom file wins.")
config = {"spec_version": 1, "name": "test-agent", "instructions": "CUSTOM.md"}
(agent_dir / "config.yaml").write_text(yaml.dump(config))
spec = parse(agent_dir)
assert spec.instructions == "Custom file wins."
def test_parse_prompt_alias_inline(tmp_path: Path) -> None:
"""``prompt:`` is an alias for ``instructions:`` (inline text)."""
config = {"spec_version": 1, "prompt": "Be concise and helpful."}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
# Without the alias, ``prompt:`` is ignored and instructions falls
# back to None (no AGENTS.md here) — the silent generic-prompt bug.
assert spec.instructions == "Be concise and helpful."
def test_parse_prompt_alias_multiline(tmp_path: Path) -> None:
"""A multiline ``prompt:`` block (the nessie config shape) loads."""
config = {
"spec_version": 1,
"name": "nessie-like",
"prompt": "You are an orchestrator.\nNever merge.\nDecompose first.",
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
assert spec.instructions == ("You are an orchestrator.\nNever merge.\nDecompose first.")
def test_parse_prompt_alias_file_reference(agent_dir: Path) -> None:
"""``prompt:`` honors the same file-path resolution as instructions."""
(agent_dir / "SYSTEM.md").write_text("Prompt body from file.")
config = {"spec_version": 1, "name": "test-agent", "prompt": "SYSTEM.md"}
(agent_dir / "config.yaml").write_text(yaml.dump(config))
spec = parse(agent_dir)
assert spec.instructions == "Prompt body from file."
def test_parse_prompt_alias_overrides_agents_md(agent_dir: Path) -> None:
"""``prompt:`` is consulted before the AGENTS.md auto-detect scan."""
(agent_dir / "AGENTS.md").write_text("Fallback instructions.")
config = {"spec_version": 1, "name": "test-agent", "prompt": "Prompt wins."}
(agent_dir / "config.yaml").write_text(yaml.dump(config))
spec = parse(agent_dir)
assert spec.instructions == "Prompt wins."
def test_parse_instructions_wins_over_prompt(tmp_path: Path) -> None:
"""When both keys are set, ``instructions:`` takes precedence."""
config = {
"spec_version": 1,
"instructions": "Canonical instructions.",
"prompt": "Legacy prompt alias.",
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
# Precedence lock: ``instructions:`` is the canonical key and carries
# file-path resolution; ``prompt:`` only fills in when it's absent.
assert spec.instructions == "Canonical instructions."
def test_auto_detect_agents_md_first_priority(agent_dir: Path) -> None:
"""AGENTS.md is chosen over CLAUDE.md and .cursorrules."""
(agent_dir / "AGENTS.md").write_text("FROM AGENTS")
(agent_dir / "CLAUDE.md").write_text("FROM CLAUDE")
(agent_dir / ".cursorrules").write_text("FROM CURSORRULES")
spec = parse(agent_dir)
assert spec.instructions == "FROM AGENTS"
def test_auto_detect_claude_md_when_no_agents_md(agent_dir: Path) -> None:
"""CLAUDE.md is chosen when AGENTS.md is absent."""
(agent_dir / "CLAUDE.md").write_text("FROM CLAUDE")
(agent_dir / ".cursorrules").write_text("FROM CURSORRULES")
spec = parse(agent_dir)
assert spec.instructions == "FROM CLAUDE"
def test_auto_detect_cursorrules_when_others_absent(agent_dir: Path) -> None:
""".cursorrules is chosen when AGENTS.md and CLAUDE.md are absent."""
(agent_dir / ".cursorrules").write_text("FROM CURSORRULES")
spec = parse(agent_dir)
assert spec.instructions == "FROM CURSORRULES"
def test_auto_detect_none_when_no_context_files(agent_dir: Path) -> None:
"""No context files present → instructions is None."""
spec = parse(agent_dir)
assert spec.instructions is None
def test_parse_skill(agent_dir: Path) -> None:
skill_dir = agent_dir / "skills" / "deep-search"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
"---\n"
"name: deep-search\n"
"description: Search the web for sources.\n"
"---\n"
"When asked to research, use search.web."
)
spec = parse(agent_dir)
assert len(spec.skills) == 1
skill = spec.skills[0]
assert skill.name == "deep-search"
assert skill.description == "Search the web for sources."
assert skill.content == "When asked to research, use search.web."
assert skill.skill_dir == skill_dir
# Absent ``user-invocable`` frontmatter defaults to invocable.
assert skill.user_invocable is True
def test_parse_skill_user_invocable_false(agent_dir: Path) -> None:
"""``user-invocable: false`` frontmatter parses to ``user_invocable=False``."""
skill_dir = agent_dir / "skills" / "internal-hook"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
"---\n"
"name: internal-hook\n"
"description: Internal orchestration skill.\n"
"user-invocable: false\n"
"---\n"
"Body."
)
spec = parse(agent_dir)
skill = next(s for s in spec.skills if s.name == "internal-hook")
assert skill.user_invocable is False
@pytest.mark.parametrize(
"raw,expected",
[
# Quoted-string spellings (YAML keeps these as ``str``, not bool) —
# the string branch of _falsey_flag, never exercised by the bare forms.
('"false"', False),
('"False"', False),
('"FALSE"', False),
('" false "', False),
('"no"', False), # extended false spellings (quoted → str)
('"off"', False),
('"0"', False),
('"true"', True),
('"yes"', True), # not in the false set
('"maybe"', True),
# Genuine YAML booleans — PyYAML parses bare false/no/off to ``bool``.
("false", False),
("no", False),
("off", False),
("true", True),
],
)
def test_parse_skill_user_invocable_string_and_bool_spellings(
agent_dir: Path, raw: str, expected: bool
) -> None:
"""Both the YAML bool ``false`` and the quoted string ``"false"`` parse falsey."""
skill_dir = agent_dir / "skills" / "flag-skill"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
f"---\nname: flag-skill\ndescription: d\nuser-invocable: {raw}\n---\nBody."
)
spec = parse(agent_dir)
skill = next(s for s in spec.skills if s.name == "flag-skill")
assert skill.user_invocable is expected
def test_parse_skill_missing_frontmatter(agent_dir: Path) -> None:
skill_dir = agent_dir / "skills" / "bad"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text("No frontmatter here.")
with pytest.raises(OmnigentError, match=r"missing YAML frontmatter"):
parse(agent_dir)
def test_parse_skill_missing_name(agent_dir: Path) -> None:
skill_dir = agent_dir / "skills" / "no-name"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text("---\ndescription: Missing name.\n---\nContent.")
with pytest.raises(OmnigentError, match=r"missing required field 'name'"):
parse(agent_dir)
def test_parse_skill_missing_description(agent_dir: Path) -> None:
skill_dir = agent_dir / "skills" / "no-desc"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text("---\nname: no-desc\n---\nContent.")
with pytest.raises(OmnigentError, match=r"missing required field 'description'"):
parse(agent_dir)
def test_parse_skill_non_utf8_raises_omnigent_error(agent_dir: Path) -> None:
"""
A non-UTF-8 SKILL.md must funnel through OmnigentError (not escape as a
bare UnicodeDecodeError) so the lenient scanner / menu providers can
catch it and skip the file instead of crashing.
"""
skill_dir = agent_dir / "skills" / "bad-bytes"
skill_dir.mkdir(parents=True)
# 0xff is invalid UTF-8 — read_text() raises UnicodeDecodeError.
(skill_dir / "SKILL.md").write_bytes(b"---\nname: bad-bytes\ndescription: \xff\n---\nx")
with pytest.raises(OmnigentError, match=r"could not be read"):
parse(agent_dir)
# Reproduces the exact ``argument-hint:`` line from the upstream
# Claude Code skill at
# https://github.com/databricks-field-eng/vibe/blob/main/plugins/fe-databricks-tools/skills/databricks-data-generation/SKILL.md
# which broke ``omnigent --harness codex`` REPL launch before the
# host-skill scanner was made tolerant. YAML reads ``[industry]``
# as a flow sequence and then chokes on the trailing ``[--rows N]``.
_UPSTREAM_BAD_ARGUMENT_HINT = (
"argument-hint: [industry] [--rows N] [--catalog NAME] [--schema NAME]"
)
def test_parse_skill_invalid_yaml_frontmatter_in_bundle_raises(
agent_dir: Path,
) -> None:
"""
Agent-bundle skills are shipped with the spec and stay strict —
a YAML parse error in the bundle's own ``skills/`` directory
must fail loud, not silently drop the skill. ``parse()`` calls
``_discover_skills`` without the ``strict=False`` opt-in, so
this test also pins the default behavior.
"""
skill_dir = agent_dir / "skills" / "bad-yaml"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
f"---\nname: bad-yaml\ndescription: x\n{_UPSTREAM_BAD_ARGUMENT_HINT}\n---\nContent."
)
with pytest.raises(OmnigentError, match=r"invalid YAML frontmatter"):
parse(agent_dir)
def test_discover_host_skills_skips_invalid_yaml_frontmatter(
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Host skill directories are user-managed (``~/.claude/skills/``,
``.claude/skills/``) and may contain third-party skills whose
frontmatter doesn't strictly parse as YAML. This test uses the
literal upstream ``argument-hint:`` line from the
``databricks-data-generation`` Claude Code skill — the exact
string that aborted ``omnigent --harness codex`` REPL launch
in production.
One bad skill must not break REPL launch: it must be logged
(with the file path so the user knows what to fix and the YAML
error so the cause is clear) and skipped, while the remaining
skills continue to load.
``discover_host_skills`` scans two locations: walking up from
``agent_root`` and ``Path.home() / ".claude" / "skills"``. We
pin ``$HOME`` at a fresh tmp dir to keep the developer's real
``~/.claude/skills/`` (which contains the actual offending
skill) out of this test.
"""
fake_home = tmp_path / "home"
fake_home.mkdir()
monkeypatch.setenv("HOME", str(fake_home))
agent_root = tmp_path / "agent"
agent_root.mkdir()
host_skills = agent_root / ".claude" / "skills"
host_skills.mkdir(parents=True)
bad_dir = host_skills / "bad-skill"
bad_dir.mkdir()
bad_md = bad_dir / "SKILL.md"
bad_md.write_text(
f"---\nname: bad-skill\ndescription: x\n{_UPSTREAM_BAD_ARGUMENT_HINT}\n---\nContent."
)
good_dir = host_skills / "good-skill"
good_dir.mkdir()
(good_dir / "SKILL.md").write_text("---\nname: good-skill\ndescription: y\n---\nContent.")
with caplog.at_level("WARNING", logger="omnigent.spec.parser"):
result = discover_host_skills(agent_root, "all")
names = [s.name for s in result]
assert names == ["good-skill"], (
"tolerant host-skill scan must drop the bad skill but keep "
"every other skill in the same directory"
)
skip_records = [rec for rec in caplog.records if "Skipping skill" in rec.message]
assert len(skip_records) == 1, "exactly one skip warning expected — one per bad skill"
msg = skip_records[0].message
# Warning must name the offending file so the user can fix it,
# and must surface the YAML parser error so the cause is clear.
assert str(bad_md) in msg, msg
assert "invalid YAML frontmatter" in msg, msg
def test_discover_host_skills_skips_unreadable_skill_file(
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
File IO errors (broken symlink, permission denied) on a host
``SKILL.md`` must funnel through the same tolerant path as
malformed-frontmatter errors. A user with a stray broken
symlink under ``~/.claude/skills/`` must not see the whole
REPL launch abort.
"""
fake_home = tmp_path / "home"
fake_home.mkdir()
monkeypatch.setenv("HOME", str(fake_home))
agent_root = tmp_path / "agent"
agent_root.mkdir()
host_skills = agent_root / ".claude" / "skills"
host_skills.mkdir(parents=True)
# Broken symlink: ``SKILL.md`` exists (in the sense that
# ``Path.exists()`` follows symlinks and returns False, but the
# discoverer's ``skill_md.exists()`` check returns False too).
# Use a directory we make read-then-unreadable instead so the
# path exists but read_text() raises OSError.
bad_dir = host_skills / "unreadable"
bad_dir.mkdir()
bad_md = bad_dir / "SKILL.md"
bad_md.write_text("---\nname: unreadable\ndescription: x\n---\nbody")
bad_md.chmod(0o000)
good_dir = host_skills / "good"
good_dir.mkdir()
(good_dir / "SKILL.md").write_text("---\nname: good\ndescription: y\n---\nContent.")
try:
with caplog.at_level("WARNING", logger="omnigent.spec.parser"):
result = discover_host_skills(agent_root, "all")
finally:
# Restore so pytest can clean tmp_path on teardown.
bad_md.chmod(0o600)
assert [s.name for s in result] == ["good"]
skip_records = [rec for rec in caplog.records if "Skipping skill" in rec.message]
assert len(skip_records) == 1
msg = skip_records[0].message
assert str(bad_md) in msg
assert "could not be read" in msg
# ── top-level ``skills:`` field (host-skill filter) ──────────────
def test_parse_skills_filter_omitted_defaults_to_all(agent_dir: Path) -> None:
"""
The top-level ``skills:`` field is optional. When omitted, the
spec defaults to ``"all"`` — every host-discovered skill is
exposed by default.
Claim: a config.yaml without ``skills:`` produces
``spec.skills_filter == "all"``. A regression that flipped
the default to ``"none"`` would silently turn every existing
agent hermetic without warning.
"""
(agent_dir / "config.yaml").write_text(yaml.dump({"spec_version": 1, "name": "x"}))
spec = parse(agent_dir)
assert spec.skills_filter == "all"
def test_parse_skills_filter_explicit_all(agent_dir: Path) -> None:
"""``skills: all`` round-trips as the string ``"all"``."""
(agent_dir / "config.yaml").write_text(
yaml.dump({"spec_version": 1, "name": "x", "skills": "all"})
)
assert parse(agent_dir).skills_filter == "all"
def test_parse_skills_filter_none(agent_dir: Path) -> None:
"""``skills: none`` round-trips as the string ``"none"``."""
(agent_dir / "config.yaml").write_text(
yaml.dump({"spec_version": 1, "name": "x", "skills": "none"})
)
assert parse(agent_dir).skills_filter == "none"
def test_parse_skills_filter_empty_list_normalizes_to_none(agent_dir: Path) -> None:
"""
``skills: []`` is an explicit "no skills" declaration —
normalizes to ``"none"`` so the executor handles both the same
way.
Claim: empty list and ``"none"`` produce identical
``skills_filter`` values. A regression that distinguished the
two would create a foot-gun (silent disagreement between two
YAML shapes that look the same to the user).
"""
(agent_dir / "config.yaml").write_text(
yaml.dump({"spec_version": 1, "name": "x", "skills": []})
)
assert parse(agent_dir).skills_filter == "none"
def test_parse_skills_filter_named_subset(agent_dir: Path) -> None:
"""A list of names round-trips as a list of names."""
(agent_dir / "config.yaml").write_text(
yaml.dump(
{
"spec_version": 1,
"name": "x",
"skills": ["foo", "bar:baz"],
}
)
)
assert parse(agent_dir).skills_filter == ["foo", "bar:baz"]
def test_parse_skills_filter_invalid_string_rejects(agent_dir: Path) -> None:
"""
Strings other than ``"all"`` / ``"none"`` are rejected at
parse time — no silent coercion of typos like ``"al"`` or
``"All"`` to a permissive default.
"""
(agent_dir / "config.yaml").write_text(
yaml.dump({"spec_version": 1, "name": "x", "skills": "al"})
)
with pytest.raises(OmnigentError, match=r"\"all\".*\"none\""):
parse(agent_dir)
def test_parse_skills_filter_non_string_list_item_rejects(agent_dir: Path) -> None:
"""
Lists with non-string entries (numbers, dicts, nested lists)
fail loud rather than coercing.
"""
(agent_dir / "config.yaml").write_text(
yaml.dump({"spec_version": 1, "name": "x", "skills": ["foo", 42]})
)
with pytest.raises(OmnigentError, match=r"list items must be strings"):
parse(agent_dir)
def test_parse_skills_filter_dict_rejects(agent_dir: Path) -> None:
"""
Mappings (and other unsupported shapes — booleans, integers)
are rejected. The field is a string or list, never a dict.
"""
(agent_dir / "config.yaml").write_text(
yaml.dump({"spec_version": 1, "name": "x", "skills": {"all": True}})
)
with pytest.raises(OmnigentError, match=r"\"all\".*\"none\""):
parse(agent_dir)
def test_parse_skills_filter_is_independent_of_bundled_skills_dir(
agent_dir: Path,
) -> None:
"""
``spec.skills`` (bundled SkillSpec list) and ``spec.skills_filter``
(host filter) are orthogonal: the bundle-side ``skills/`` dir
and the YAML ``skills:`` field don't shadow each other.
Claim: a bundle with a ``skills/researcher/SKILL.md`` AND a
YAML ``skills: none`` field parses both: ``spec.skills`` has
one entry (the bundled researcher), and ``spec.skills_filter``
is ``"none"``. A regression that conflated them would lose
bundled skills when the user opted out of host skills, or
vice versa.
"""
skill_dir = agent_dir / "skills" / "researcher"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
"---\nname: researcher\ndescription: Research things.\n---\nDo research.\n"
)
(agent_dir / "config.yaml").write_text(
yaml.dump({"spec_version": 1, "name": "x", "skills": "none"})
)
spec = parse(agent_dir)
# Bundled skill is preserved.
assert len(spec.skills) == 1
assert spec.skills[0].name == "researcher"
# And the host filter says "none" — bundled and host are
# separate channels.
assert spec.skills_filter == "none"
# ── lenient host-skill discovery ────────────────────
def test_discover_host_skills_skips_missing_frontmatter(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""
Host skills with missing YAML frontmatter are skipped with a
warning instead of crashing the CLI.
:param tmp_path: Temporary directory for test fixtures.
:param monkeypatch: Pytest monkeypatch for isolating ``Path.home()``.
:param capsys: Pytest capture fixture for stderr assertions.
"""
from omnigent.spec.parser import discover_host_skills
# Use a separate home dir so the walk-up from agent_root
# doesn't double-scan the same .claude/skills/ as Path.home().
fake_home = tmp_path / "home"
fake_home.mkdir()
monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home))
skills_dir = fake_home / ".claude" / "skills"
# Good skill.
good = skills_dir / "good-skill"
good.mkdir(parents=True)
(good / "SKILL.md").write_text(
"---\nname: good-skill\ndescription: Works fine.\n---\nContent."
)
# Bad skill — no frontmatter.
bad = skills_dir / "bad-skill"
bad.mkdir(parents=True)
(bad / "SKILL.md").write_text("# No frontmatter here")
agent_root = tmp_path / "project"
agent_root.mkdir()
result = discover_host_skills(agent_root, skills_filter="all")
assert len(result) == 1
assert result[0].name == "good-skill"
captured = capsys.readouterr()
assert "skipped 1 skill(s)" in captured.err
assert "bad-skill" in captured.err
def test_discover_host_skills_skips_yaml_syntax_error(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""
Host skills whose frontmatter contains invalid YAML (e.g.
unquoted colons) are skipped gracefully.
:param tmp_path: Temporary directory for test fixtures.
:param monkeypatch: Pytest monkeypatch for isolating ``Path.home()``.
:param capsys: Pytest capture fixture for stderr assertions.
"""
from omnigent.spec.parser import discover_host_skills
fake_home = tmp_path / "home"
fake_home.mkdir()
monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home))
skills_dir = fake_home / ".claude" / "skills"
broken = skills_dir / "broken-yaml"
broken.mkdir(parents=True)
# Unquoted colon in description triggers yaml.scanner.ScannerError.
(broken / "SKILL.md").write_text(
"---\nname: broken-yaml\ndescription: TRIGGER when: code imports foo\n---\nContent."
)
agent_root = tmp_path / "project"
agent_root.mkdir()
result = discover_host_skills(agent_root, skills_filter="all")
assert result == []
captured = capsys.readouterr()
assert "skipped 1 skill(s)" in captured.err
assert "broken-yaml" in captured.err
def test_discover_host_skills_skips_multiple_bad_skills(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""
All broken skills are reported in one pass — no whack-a-mole.
:param tmp_path: Temporary directory for test fixtures.
:param monkeypatch: Pytest monkeypatch for isolating ``Path.home()``.
:param capsys: Pytest capture fixture for stderr assertions.
"""
from omnigent.spec.parser import discover_host_skills
fake_home = tmp_path / "home"
fake_home.mkdir()
monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home))
skills_dir = fake_home / ".claude" / "skills"
for name in ("bad-a", "bad-b"):
d = skills_dir / name
d.mkdir(parents=True)
(d / "SKILL.md").write_text("No frontmatter.")
agent_root = tmp_path / "project"
agent_root.mkdir()
result = discover_host_skills(agent_root, skills_filter="all")
assert result == []
captured = capsys.readouterr()
assert "skipped 2 skill(s)" in captured.err
assert "bad-a" in captured.err
assert "bad-b" in captured.err
def test_bundled_skills_still_fail_loud_on_bad_frontmatter(
agent_dir: Path,
) -> None:
"""
Bundled skills (inside the agent directory, parsed by
:func:`parse`) must still fail loud — lenient mode is only
for host-discovered skills.
:param agent_dir: Temporary agent directory fixture.
"""
skill_dir = agent_dir / "skills" / "broken"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text("No frontmatter here.")
with pytest.raises(OmnigentError, match=r"missing YAML frontmatter"):
parse(agent_dir)
def test_parse_mcp_http(
agent_dir: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Parse an HTTP MCP server config with env var expansion.
:param agent_dir: Temporary agent directory fixture.
:param monkeypatch: Pytest monkeypatch for env vars.
"""
monkeypatch.setenv("API_KEY", "sk-test-key")
mcp_dir = agent_dir / "tools" / "mcp"
mcp_dir.mkdir(parents=True)
mcp_config = {
"name": "my-service",
"transport": "http",
"url": "http://localhost:9000/mcp",
"headers": {"Authorization": "Bearer ${API_KEY}"},
}
(mcp_dir / "service.yaml").write_text(yaml.dump(mcp_config))
spec = parse(agent_dir)
mcp = spec.mcp_servers[0]
assert mcp.url == "http://localhost:9000/mcp"
# ${API_KEY} expanded to the value set via monkeypatch.
assert mcp.headers == {"Authorization": "Bearer sk-test-key"}
def test_parse_mcp_env_unresolved_var_raises(
agent_dir: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Unresolved ``${VAR}`` in MCP env raises ``OmnigentError``
at parse time instead of silently passing the literal to the
server.
:param agent_dir: Temporary agent directory fixture.
:param monkeypatch: Pytest monkeypatch for env vars.
"""
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
mcp_dir = agent_dir / "tools" / "mcp"
mcp_dir.mkdir(parents=True)
mcp_config = {
"name": "github",
"transport": "http",
"url": "http://localhost:9000/mcp",
"headers": {"Authorization": "Bearer ${GITHUB_TOKEN}"},
}
(mcp_dir / "github.yaml").write_text(yaml.dump(mcp_config))
with pytest.raises(OmnigentError, match=r"Unresolved environment variable"):
parse(agent_dir)
def test_parse_mcp_headers_unresolved_var_raises(
agent_dir: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Unresolved ``${VAR}`` in MCP headers raises ValueError at
parse time.
:param agent_dir: Temporary agent directory fixture.
:param monkeypatch: Pytest monkeypatch for env vars.
"""
monkeypatch.delenv("API_KEY", raising=False)
mcp_dir = agent_dir / "tools" / "mcp"
mcp_dir.mkdir(parents=True)
mcp_config = {
"name": "my-service",
"transport": "http",
"url": "http://localhost:9000/mcp",
"headers": {"Authorization": "Bearer ${API_KEY}"},
}
(mcp_dir / "service.yaml").write_text(yaml.dump(mcp_config))
with pytest.raises(OmnigentError, match=r"Unresolved environment variable"):
parse(agent_dir)
def test_parse_mcp_env_dollar_without_braces_raises(
agent_dir: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Unresolved ``$VAR`` (without braces) also raises ValueError.
:param agent_dir: Temporary agent directory fixture.
:param monkeypatch: Pytest monkeypatch for env vars.
"""
monkeypatch.delenv("MY_SECRET", raising=False)
mcp_dir = agent_dir / "tools" / "mcp"
mcp_dir.mkdir(parents=True)
mcp_config = {
"name": "test",
"transport": "http",
"url": "http://localhost:9000/mcp",
"headers": {"Secret": "$MY_SECRET"},
}
(mcp_dir / "test.yaml").write_text(yaml.dump(mcp_config))
with pytest.raises(OmnigentError, match=r"Unresolved environment variable"):
parse(agent_dir)
def test_parse_mcp_missing_name(agent_dir: Path) -> None:
mcp_dir = agent_dir / "tools" / "mcp"
mcp_dir.mkdir(parents=True)
(mcp_dir / "bad.yaml").write_text(yaml.dump({"transport": "http", "url": "http://x"}))
with pytest.raises(OmnigentError, match=r"missing required field 'name'"):
parse(agent_dir)
def test_parse_mcp_missing_transport(agent_dir: Path) -> None:
mcp_dir = agent_dir / "tools" / "mcp"
mcp_dir.mkdir(parents=True)
(mcp_dir / "bad.yaml").write_text(yaml.dump({"name": "bad"}))
with pytest.raises(OmnigentError, match=r"missing required field 'transport'"):
parse(agent_dir)
def test_parse_inline_mcp_stdio_server(tmp_path: Path) -> None:
"""
A ``tools:`` block entry with ``type: mcp`` and ``command`` parses
as a stdio MCPServerConfig.
Exercises the ``_parse_inline_mcp_servers`` code path (the tools-block
style, distinct from bundle-file discovery via ``tools/mcp/*.yaml``).
If the inline path were broken, ``spec.mcp_servers`` would be empty
even though the config declares the server.
"""
config = {
"spec_version": 1,
"name": "inline-stdio",
"tools": {
"my_mcp": {
"type": "mcp",
"command": "uvx",
"args": ["mcp-server-github"],
}
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
# Exactly one server parsed from the inline tools block.
# If _parse_inline_mcp_servers skips it, len() == 0.
assert len(spec.mcp_servers) == 1
srv = spec.mcp_servers[0]
assert srv.name == "my_mcp"
# command present → transport inferred as "stdio"
assert srv.transport == "stdio"
assert srv.command == "uvx"
assert srv.args == ["mcp-server-github"]
# stdio servers have no url
assert srv.url is None
def test_parse_inline_mcp_http_server(tmp_path: Path) -> None:
"""
A ``tools:`` block entry with ``type: mcp`` and ``url`` parses
as an http MCPServerConfig with the optional description preserved.
If the inline path were broken, ``spec.mcp_servers`` would be empty.
If transport inference were wrong, ``srv.transport`` would not be
``"http"``.
"""
config = {
"spec_version": 1,
"name": "inline-http",
"tools": {
"my_service": {
"type": "mcp",
"url": "http://localhost:9000/mcp",
"description": "My HTTP service",
}
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
assert len(spec.mcp_servers) == 1
srv = spec.mcp_servers[0]
assert srv.name == "my_service"
# url present → transport inferred as "http"
assert srv.transport == "http"
assert srv.url == "http://localhost:9000/mcp"
assert srv.description == "My HTTP service"
assert srv.command is None
assert srv.args == []
def test_parse_inline_mcp_skips_standard_tools_keys(tmp_path: Path) -> None:
"""
The standard ``tools:`` block keys (``agents``, ``builtins``,
``timeout``, ``retry``, ``sandbox``) are not mistaken for MCP
server entries.
If any standard key slipped through ``_TOOLS_CONFIG_KEYS``, the
server would emit a spurious MCPServerConfig (with wrong transport)
or raise because the value shape doesn't match.
"""
config = {
"spec_version": 1,
"name": "with-standard-keys",
"tools": {
"agents": ["researcher"],
"builtins": ["web_search"],
"timeout": 30,
"retry": {"max_attempts": 3},
"sandbox": True,
# Only this entry should appear in mcp_servers
"real_mcp": {"type": "mcp", "command": "uvx"},
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
# Only the real_mcp entry surfaces — the 5 standard keys are filtered.
# If any standard key leaked through, len() would be > 1.
assert len(spec.mcp_servers) == 1
assert spec.mcp_servers[0].name == "real_mcp"
def test_parse_tools_sandbox_docker_image_alias(tmp_path: Path) -> None:
"""Legacy ``tools.sandbox.docker_image`` remains a valid image alias."""
config = {
"spec_version": 1,
"name": "legacy-docker-image",
"tools": {
"sandbox": {
"docker_image": "python:3.12-slim",
"container_runtime": "podman",
},
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
assert spec.tools.sandbox.container_image == "python:3.12-slim"
assert spec.tools.sandbox.docker_image == "python:3.12-slim"
assert spec.tools.sandbox.container_runtime == "podman"
def test_parse_tools_sandbox_container_image_precedence(tmp_path: Path) -> None:
"""Preferred ``container_image`` wins when both image keys exist."""
config = {
"spec_version": 1,
"name": "container-image-precedence",
"tools": {
"sandbox": {
"container_image": "python:3.12-slim",
"docker_image": "python:3.11-slim",
},
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
assert spec.tools.sandbox.container_image == "python:3.12-slim"
assert spec.tools.sandbox.docker_image == "python:3.12-slim"
def test_parse_inline_mcp_skips_non_mcp_type_entries(tmp_path: Path) -> None:
"""
Tools-block entries whose ``type`` is not ``"mcp"`` are silently
ignored by the inline parser.
Fails if any non-mcp entry is incorrectly treated as an MCP server.
"""
config = {
"spec_version": 1,
"name": "mixed-tools",
"tools": {
"python_tool": {"type": "python", "path": "tools/python/foo.py"},
"mcp_tool": {"type": "mcp", "command": "uvx"},
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
# python_tool must be skipped; only the mcp entry surfaces.
assert len(spec.mcp_servers) == 1
assert spec.mcp_servers[0].name == "mcp_tool"
def test_parse_inline_mcp_databricks_only_skipped(tmp_path: Path) -> None:
"""
An inline ``type: mcp`` entry with no ``command`` or ``url``
(only ``databricks_server``) is silently skipped because no
transport can be inferred.
If the skip were missing, parse() would raise or produce a
server with an incorrect transport.
"""
config = {
"spec_version": 1,
"name": "db-only",
"tools": {
"db_mcp": {
"type": "mcp",
"databricks_server": {"name": "some_server"},
}
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
# No command/url → transport unresolvable → entry skipped.
# If the skip were removed, mcp_servers would be non-empty.
assert spec.mcp_servers == []
def test_parse_inline_mcp_headers_and_env_expanded(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""
Inline ``type: mcp`` entries expand ``${VAR}`` in ``headers``
(http transport) and ``env`` (stdio transport).
If the inline parser dropped these fields (the pre-fix behavior),
both ``headers`` and ``env`` would be empty dicts.
"""
monkeypatch.setenv("MCP_TOKEN", "secret-123")
monkeypatch.setenv("MY_KEY", "val-456")
config = {
"spec_version": 1,
"name": "inline-expand",
"tools": {
"svc": {
"type": "mcp",
"url": "http://localhost/mcp",
"headers": {"Authorization": "Bearer ${MCP_TOKEN}"},
},
"cli": {
"type": "mcp",
"command": "my-mcp",
"env": {"MY_KEY": "${MY_KEY}"},
},
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
http_srv = next(s for s in spec.mcp_servers if s.name == "svc")
assert http_srv.headers == {"Authorization": "Bearer secret-123"}
stdio_srv = next(s for s in spec.mcp_servers if s.name == "cli")
assert stdio_srv.env == {"MY_KEY": "val-456"}
def test_parse_inline_mcp_rejects_non_dict_headers(tmp_path: Path) -> None:
"""
Non-dict ``headers`` on an inline MCP entry raises
``OmnigentError`` instead of silently falling back to ``{}``.
Without the validation, a typo like ``headers: "Bearer tok"``
would be silently ignored and the MCP server would connect
unauthenticated.
"""
config = {
"spec_version": 1,
"name": "bad-headers",
"tools": {
"svc": {
"type": "mcp",
"url": "http://localhost/mcp",
"headers": "Bearer tok",
}
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
with pytest.raises(OmnigentError, match=r"headers.*must be a mapping"):
parse(tmp_path)
def test_parse_inline_mcp_rejects_non_dict_env(tmp_path: Path) -> None:
"""
Non-dict ``env`` on an inline stdio MCP entry raises
``OmnigentError`` instead of silently falling back to ``{}``.
Without the validation, ``env: "FOO=bar"`` would be silently
dropped and the subprocess would launch without the intended
environment variable.
"""
config = {
"spec_version": 1,
"name": "bad-env",
"tools": {
"cli": {
"type": "mcp",
"command": "my-mcp",
"env": "FOO=bar",
}
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
with pytest.raises(OmnigentError, match=r"env.*must be a mapping"):
parse(tmp_path)
def test_parse_inline_and_bundle_mcp_combined(tmp_path: Path) -> None:
"""
Inline ``tools:`` block MCP entries and ``tools/mcp/*.yaml`` bundle
files are both collected and merged into ``spec.mcp_servers``.
Verifies that the two code paths (``_discover_mcp_servers`` and
``_parse_inline_mcp_servers``) are concatenated, not one silently
shadowing the other. If only one path ran, len() would be 1.
"""
# Bundle-file MCP server (no auth so no env-var expansion needed)
mcp_dir = tmp_path / "tools" / "mcp"
mcp_dir.mkdir(parents=True)
(mcp_dir / "bundle_srv.yaml").write_text(
yaml.dump(
{
"name": "bundle_srv",
"transport": "http",
"url": "http://bundle.example.com/mcp",
}
)
)
# config.yaml also declares an inline MCP server
config = {
"spec_version": 1,
"name": "combined",
"tools": {
"inline_mcp": {"type": "mcp", "command": "uvx"},
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
# Both sources contribute — two distinct entries.
# If only one path ran, len() would be 1.
assert len(spec.mcp_servers) == 2
names = {srv.name for srv in spec.mcp_servers}
assert names == {"bundle_srv", "inline_mcp"}
def test_parse_local_python_tools(agent_dir: Path) -> None:
py_dir = agent_dir / "tools" / "python"
py_dir.mkdir(parents=True)
(py_dir / "arxiv_search.py").write_text("def search(): pass")
(py_dir / "web_scrape.py").write_text("def scrape(): pass")
spec = parse(agent_dir)
assert len(spec.local_tools) == 2
names = {t.name for t in spec.local_tools}
assert names == {"arxiv_search", "web_scrape"}
assert all(t.language == "python" for t in spec.local_tools)
def test_parse_local_typescript_tools(agent_dir: Path) -> None:
ts_dir = agent_dir / "tools" / "typescript"
ts_dir.mkdir(parents=True)
(ts_dir / "code_run.ts").write_text("export function run() {}")
spec = parse(agent_dir)
assert len(spec.local_tools) == 1
assert spec.local_tools[0].name == "code_run"
assert spec.local_tools[0].language == "typescript"
def test_parse_sub_agents(tmp_path: Path) -> None:
# Parent config referencing two sub-agents
parent_config = {
"spec_version": 1,
"name": "parent",
"tools": {"agents": ["researcher", "critic"]},
}
(tmp_path / "config.yaml").write_text(yaml.dump(parent_config))
# Sub-agent: researcher
researcher_dir = tmp_path / "agents" / "researcher"
researcher_dir.mkdir(parents=True)
(researcher_dir / "config.yaml").write_text(
yaml.dump({"spec_version": 1, "name": "researcher"})
)
# Sub-agent: critic
critic_dir = tmp_path / "agents" / "critic"
critic_dir.mkdir(parents=True)
(critic_dir / "config.yaml").write_text(yaml.dump({"spec_version": 1, "name": "critic"}))
spec = parse(tmp_path)
assert len(spec.sub_agents) == 2
sub_names = {sa.name for sa in spec.sub_agents}
assert sub_names == {"researcher", "critic"}
def test_parse_interaction_defaults(agent_dir: Path) -> None:
"""Omitting interaction block entirely gives defaults."""
spec = parse(agent_dir)
assert spec.interaction.conversational is True
assert spec.interaction.modalities.input == ["text"]
assert spec.interaction.modalities.output == ["text"]
def test_parse_interaction_partial_modalities(tmp_path: Path) -> None:
"""Omitting one side of modalities defaults that side to [text]."""
config = {
"spec_version": 1,
"interaction": {"modalities": {"input": ["text", "image"]}},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
assert spec.interaction.modalities.input == ["text", "image"]
assert spec.interaction.modalities.output == ["text"]
def test_parse_os_env_absent_yields_none(agent_dir: Path) -> None:
"""A native YAML without an ``os_env:`` block leaves
``spec.os_env`` as ``None`` — no sys_os_* tools registered.
What breaks if this fails: the runtime would build a default
:class:`OSEnvironment` for every agent and silently expose
``sys_os_read/write/edit/shell`` on agents that never opted
into them, regressing the "no os_env declared = no FS access"
contract from the omnigent-compat path.
"""
spec = parse(agent_dir)
assert spec.os_env is None
def test_parse_os_env_caller_process(tmp_path: Path) -> None:
"""A native YAML ``os_env:`` mapping parses into a real
:class:`OSEnvSpec` with the declared ``type`` and ``cwd``.
What breaks if this fails: native Omnigent YAMLs cannot opt into
sys_os_* tools — the whole point of step 5l.
"""
from omnigent.inner.datamodel import OSEnvSpec
config = {
"spec_version": 1,
"name": "with-os-env",
"os_env": {
"type": "caller_process",
"cwd": ".",
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
# Real OSEnvSpec dataclass — not a dict — so the runtime's
# isinstance check in ToolManager._register_os_env_tools
# registers the sys_os_* tools.
assert isinstance(spec.os_env, OSEnvSpec)
assert spec.os_env.type == "caller_process"
assert spec.os_env.cwd == "."
# Sandbox absent → None (the wrap then defaults appropriately).
assert spec.os_env.sandbox is None
assert spec.os_env.fork is False
def test_parse_os_env_with_sandbox(tmp_path: Path) -> None:
"""The nested ``sandbox:`` block parses into a real
:class:`OSEnvSandboxSpec` with all its fields.
What breaks if this fails: agents that declared sandbox /
write-path constraints in YAML would silently lose them at
runtime, leaving sys_os_* tools running with the agent's
full process privileges.
"""
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec
config = {
"spec_version": 1,
"name": "with-sandbox",
"os_env": {
"type": "caller_process",
"cwd": ".",
"sandbox": {
"type": "linux_bwrap",
"read_paths": ["/usr"],
"write_paths": ["."],
"write_files": ["/home/me/.claude.json"],
"allow_network": False,
},
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
assert isinstance(spec.os_env, OSEnvSpec)
assert isinstance(spec.os_env.sandbox, OSEnvSandboxSpec)
sandbox = spec.os_env.sandbox
assert sandbox.type == "linux_bwrap"
assert sandbox.read_paths == ["/usr"]
assert sandbox.write_paths == ["."]
# write_files is the per-file grant carve-out for files like
# ~/.claude.json that can't be expressed as a directory write
# path — the parser must thread it through.
assert sandbox.write_files == ["/home/me/.claude.json"]
assert sandbox.allow_network is False
def test_parse_os_env_non_mapping_raises(tmp_path: Path) -> None:
"""A scalar/list under ``os_env:`` raises OmnigentError —
fail loud rather than silently dropping the malformed block.
"""
config = {
"spec_version": 1,
"name": "bad-os-env",
"os_env": "caller_process",
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
with pytest.raises(OmnigentError, match=r"os_env must be a YAML mapping"):
parse(tmp_path)
def test_parse_os_env_sandbox_non_mapping_raises(tmp_path: Path) -> None:
"""A scalar/list under ``os_env.sandbox:`` raises
OmnigentError — same fail-loud contract as the parent.
"""
config = {
"spec_version": 1,
"name": "bad-sandbox",
"os_env": {"type": "caller_process", "sandbox": "linux_bwrap"},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
with pytest.raises(OmnigentError, match=r"os_env.sandbox must be a YAML mapping"):
parse(tmp_path)
def test_parse_os_env_sandbox_with_cwd_allow_hidden(tmp_path: Path) -> None:
"""
``cwd_allow_hidden`` parses through to
:class:`OSEnvSandboxSpec.cwd_allow_hidden` verbatim. The bwrap
backend reads this list and skips masking those names; default
substitution (``[".venv"]``) happens in the bwrap resolver, not
here, so the parser must preserve ``None`` vs empty list vs
non-empty list distinctions.
"""
config = {
"spec_version": 1,
"name": "with-allow-hidden",
"os_env": {
"type": "caller_process",
"sandbox": {
"type": "linux_bwrap",
"cwd_allow_hidden": [".venv", ".cache"],
},
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
assert spec.os_env is not None
assert spec.os_env.sandbox is not None
assert spec.os_env.sandbox.cwd_allow_hidden == [".venv", ".cache"]
def test_parse_os_env_sandbox_cwd_allow_hidden_empty_list_preserved(
tmp_path: Path,
) -> None:
"""
An explicit empty list must NOT collapse to ``None``. The
distinction matters: ``None`` triggers the bwrap resolver's
documented default (``[".venv"]``); ``[]`` means "mask every
dotfile, allow nothing." A parser that conflates the two would
silently re-expose ``.venv`` to a hardened-mode user.
"""
config = {
"spec_version": 1,
"name": "empty-allow-hidden",
"os_env": {
"type": "caller_process",
"sandbox": {"type": "linux_bwrap", "cwd_allow_hidden": []},
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
assert spec.os_env is not None
assert spec.os_env.sandbox is not None
assert spec.os_env.sandbox.cwd_allow_hidden == []
@pytest.mark.parametrize(
"bad_value,match_regex",
[
(".venv", r"must be a list"),
([".venv", 7], r"must be strings"),
([".venv", ""], r"must not be empty"),
([".venv/secret"], r"single path components"),
(["../etc"], r"single path components"),
],
ids=["scalar", "non_string_entry", "empty_string", "with_slash", "traversal"],
)
def test_parse_os_env_sandbox_cwd_allow_hidden_validation(
tmp_path: Path, bad_value: object, match_regex: str
) -> None:
"""
Invalid ``cwd_allow_hidden`` values raise
:class:`OmnigentError` at parse time with a message that
points the author at the rule they violated.
Validation is the only thing standing between a typo'd YAML
and a sandbox that exposes ``../etc`` (path traversal) — fail
loud is the right contract here.
"""
config = {
"spec_version": 1,
"name": "bad-allow-hidden",
"os_env": {
"type": "caller_process",
"sandbox": {"type": "linux_bwrap", "cwd_allow_hidden": bad_value},
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
with pytest.raises(OmnigentError, match=match_regex):
parse(tmp_path)
def test_parse_os_env_sandbox_cwd_hidden_scan_defaults(tmp_path: Path) -> None:
"""
When the spec omits ``cwd_hidden_scan_max_entries`` and
``cwd_hidden_scan_overflow``, the parsed
:class:`OSEnvSandboxSpec` carries the dataclass defaults (cap
50000, overflow ``"warn"``).
Pinning these defaults here means a future change to the
dataclass surfaces in this test rather than silently shifting
the contract everyone depends on. The overflow default is
``"warn"`` (best-effort partial mask) rather than ``"error"`` so
heavy-but-trusted trees like ``node_modules`` don't block every
spawn; untrusted trees opt back into ``"error"``.
"""
config = {
"spec_version": 1,
"name": "default-scan",
"os_env": {
"type": "caller_process",
"sandbox": {"type": "linux_bwrap"},
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
assert spec.os_env is not None and spec.os_env.sandbox is not None
assert spec.os_env.sandbox.cwd_hidden_scan_max_entries == 50000
assert spec.os_env.sandbox.cwd_hidden_scan_overflow == "warn"
def test_parse_os_env_sandbox_cwd_hidden_scan_explicit_values(tmp_path: Path) -> None:
"""
Explicit ``cwd_hidden_scan_max_entries`` + ``..._overflow`` values
pass through to the spec.
"""
config = {
"spec_version": 1,
"name": "tuned-scan",
"os_env": {
"type": "caller_process",
"sandbox": {
"type": "linux_bwrap",
"cwd_hidden_scan_max_entries": 100000,
"cwd_hidden_scan_overflow": "warn",
},
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
assert spec.os_env is not None and spec.os_env.sandbox is not None
assert spec.os_env.sandbox.cwd_hidden_scan_max_entries == 100000
assert spec.os_env.sandbox.cwd_hidden_scan_overflow == "warn"
@pytest.mark.parametrize(
"bad_value,match_regex",
[
("not-a-number", r"must be an integer"),
(True, r"must be an integer"),
(0, r"must be > 0"),
(-1, r"must be > 0"),
],
ids=["string", "bool", "zero", "negative"],
)
def test_parse_os_env_sandbox_cwd_hidden_scan_max_entries_validation(
tmp_path: Path, bad_value: object, match_regex: str
) -> None:
"""
Non-integer or non-positive caps fail at parse time. The bool
rejection is intentional — YAML scalars are loose, and ``True``
masquerading as ``1`` would be a confusing accident.
"""
config = {
"spec_version": 1,
"name": "bad-cap",
"os_env": {
"type": "caller_process",
"sandbox": {
"type": "linux_bwrap",
"cwd_hidden_scan_max_entries": bad_value,
},
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
with pytest.raises(OmnigentError, match=match_regex):
parse(tmp_path)
@pytest.mark.parametrize(
"bad_value",
["fail", "ignore", 42, ["warn"]],
ids=["unknown_string", "synonym_attempt", "int", "list"],
)
def test_parse_os_env_sandbox_cwd_hidden_scan_overflow_validation(
tmp_path: Path, bad_value: object
) -> None:
"""
Only ``"error"``, ``"warn"``, ``"unlimited"`` are accepted.
Anything else fails at parse time so misconfigurations don't
silently degrade to default behavior.
"""
config = {
"spec_version": 1,
"name": "bad-overflow",
"os_env": {
"type": "caller_process",
"sandbox": {
"type": "linux_bwrap",
"cwd_hidden_scan_overflow": bad_value,
},
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
with pytest.raises(OmnigentError, match=r"must be one of"):
parse(tmp_path)
def test_parse_ignores_unknown_files(agent_dir: Path) -> None:
"""Parser ignores files/directories not in the spec."""
(agent_dir / "README.md").write_text("Ignored")
(agent_dir / "extra_dir").mkdir()
(agent_dir / "extra_dir" / "stuff.txt").write_text("Ignored")
spec = parse(agent_dir)
assert spec.name == "test-agent"
def test_parse_multiple_skills_sorted(agent_dir: Path) -> None:
"""Skills are discovered in sorted directory order."""
for name in ["beta-skill", "alpha-skill"]:
skill_dir = agent_dir / "skills" / name
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: Skill {name}.\n---\nContent."
)
spec = parse(agent_dir)
assert [s.name for s in spec.skills] == ["alpha-skill", "beta-skill"]
# ── Env var expansion in MCP configs ───────────────────
def test_mcp_env_vars_expanded_from_environment(
agent_dir: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
``${VAR}`` references in MCP env and headers are expanded
against the process environment at parse time.
"""
monkeypatch.setenv("MY_TOKEN", "secret-123")
mcp_dir = agent_dir / "tools" / "mcp"
mcp_dir.mkdir(parents=True)
mcp_config = {
"name": "token-server",
"transport": "http",
"url": "http://localhost:9000/mcp",
"headers": {"Authorization": "Bearer ${MY_TOKEN}"},
}
(mcp_dir / "token.yaml").write_text(yaml.dump(mcp_config))
spec = parse(agent_dir)
assert spec.mcp_servers[0].headers == {"Authorization": "Bearer secret-123"}
def test_mcp_headers_expanded_from_environment(
agent_dir: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
``${VAR}`` references in HTTP headers are expanded at parse
time.
"""
monkeypatch.setenv("MY_API_KEY", "key-abc")
mcp_dir = agent_dir / "tools" / "mcp"
mcp_dir.mkdir(parents=True)
mcp_config = {
"name": "auth-service",
"transport": "http",
"url": "http://localhost:9000/mcp",
"headers": {"Authorization": "Bearer ${MY_API_KEY}"},
}
(mcp_dir / "auth.yaml").write_text(yaml.dump(mcp_config))
spec = parse(agent_dir)
assert spec.mcp_servers[0].headers == {
"Authorization": "Bearer key-abc",
}
def test_mcp_env_expansion_mixed_set_and_unset_raises(
agent_dir: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
If any env value contains an unresolved ``${VAR}``, parsing
raises ValueError even when other vars are set.
:param agent_dir: Temporary agent directory fixture.
:param monkeypatch: Pytest monkeypatch for env vars.
"""
monkeypatch.setenv("SET_VAR", "expanded")
monkeypatch.delenv("UNSET_VAR", raising=False)
mcp_dir = agent_dir / "tools" / "mcp"
mcp_dir.mkdir(parents=True)
mcp_config = {
"name": "mixed",
"transport": "http",
"url": "http://localhost:9000/mcp",
"headers": {
"A": "${SET_VAR}",
"B": "${UNSET_VAR}",
"C": "plain-value",
},
}
(mcp_dir / "mixed.yaml").write_text(yaml.dump(mcp_config))
with pytest.raises(OmnigentError, match=r"Unresolved environment variable"):
parse(agent_dir)
# ── MCP required field validation ─────────────────────
def test_mcp_missing_url_raises(agent_dir: Path) -> None:
"""
Parser rejects an MCP config with ``transport: http`` but no
``url`` field.
:param agent_dir: Temporary agent directory with minimal
``config.yaml``.
"""
mcp_dir = agent_dir / "tools" / "mcp"
mcp_dir.mkdir(parents=True)
mcp_config = {
"name": "no-url-server",
"transport": "http",
# url intentionally omitted
}
(mcp_dir / "no_url.yaml").write_text(yaml.dump(mcp_config))
with pytest.raises(OmnigentError, match=r"missing required field 'url'"):
parse(agent_dir)
# ── Timeout / retry / execution parsing ────────────────
def test_parse_llm_timeout_and_retry(tmp_path: Path) -> None:
"""LLM block with explicit request_timeout and retry overrides."""
config = {
"spec_version": 1,
"llm": {
"model": "openai/gpt-5.4",
"request_timeout": 120,
"retry": {
"max_retries": 5,
"retryable_status_codes": [429, 502],
},
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
assert spec.llm is not None
# Explicit request_timeout should override the 300s default.
# Failure means the parser ignores the request_timeout key.
assert spec.llm.request_timeout == 120
# Retry max_retries should match the YAML value.
# Failure means retry block is not parsed or defaults are used instead.
assert spec.llm.retry.max_retries == 5
# Status codes should reflect the custom list, not the defaults.
# Failure means the parser falls back to default status codes.
assert spec.llm.retry.retryable_status_codes == (429, 502)
def test_parse_llm_timeout_defaults(tmp_path: Path) -> None:
"""LLM block with only model inherits default timeout and retry."""
config = {
"spec_version": 1,
"llm": {"model": "openai/gpt-4o"},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
assert spec.llm is not None
# Default LLM request_timeout is 300s per LLMConfig dataclass.
# Failure means the parser sets a different default.
assert spec.llm.request_timeout == 300
# Default retry max_retries is 7 per RetryPolicy dataclass.
# Failure means the parser produces a non-default retry config.
assert spec.llm.retry.max_retries == 7
def test_parse_llm_profile_survives_consolidation(tmp_path: Path) -> None:
"""``llm.profile`` must survive the llm/executor consolidation rebuild.
When an ``llm:`` block is present, ``parse`` rebuilds ``LLMConfig`` to
keep model/connection in sync with the authoritative executor fields.
That rebuild used to omit ``profile``, silently dropping the credentials
profile. Downstream, the policy/guardrail builder resolves a Databricks
workspace connection from ``spec.llm.profile``
(``omnigent/runtime/policies/builder.py``), so losing it makes those
paths fall back to env/default auth instead of the declared profile.
Regression guard: pre-fix ``spec.llm.profile`` is ``None`` here.
"""
config = {
"spec_version": 1,
"llm": {
"model": "databricks/databricks-claude-sonnet-4",
"profile": "my-workspace",
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
assert spec.llm is not None
assert spec.llm.profile == "my-workspace", (
f"spec.llm.profile is {spec.llm.profile!r}, expected 'my-workspace' — the "
"consolidation rebuild dropped the declared credentials profile."
)
def test_parse_tools_global_timeout_and_retry(tmp_path: Path) -> None:
"""Tools block with explicit timeout and retry overrides."""
config = {
"spec_version": 1,
"tools": {
"timeout": 30,
"retry": {"max_retries": 4},
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
# Explicit tools timeout should override the 60s default.
# Failure means the parser ignores the tools timeout key.
assert spec.tools.timeout == 30
# Retry max_retries should match the YAML value.
# Failure means the tools retry block is not parsed.
assert spec.tools.retry.max_retries == 4
def test_parse_builtins_string_entries(tmp_path: Path) -> None:
"""Plain string entries in tools.builtins produce BuiltinToolConfig
with empty config dicts."""
config = {
"spec_version": 1,
"tools": {
"builtins": ["web_search", "web_search_alt"],
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
# Two entries parsed, both with empty config.
assert len(spec.tools.builtins) == 2
assert spec.tools.builtins[0].name == "web_search"
assert spec.tools.builtins[0].config == {}
assert spec.tools.builtins[1].name == "web_search_alt"
assert spec.tools.builtins[1].config == {}
def test_parse_builtins_dict_entries(tmp_path: Path) -> None:
"""Dict entries in tools.builtins carry tool-specific config."""
config = {
"spec_version": 1,
"tools": {
"builtins": [
{
"name": "web_search_alt",
"api_key": "AIza-test",
"engine_id": "eng-123",
},
],
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
assert len(spec.tools.builtins) == 1
entry = spec.tools.builtins[0]
assert entry.name == "web_search_alt"
# Config contains all keys except 'name'.
assert entry.config == {
"api_key": "AIza-test",
"engine_id": "eng-123",
}
def test_parse_builtins_mixed_entries(tmp_path: Path) -> None:
"""tools.builtins supports a mix of strings and dicts."""
config = {
"spec_version": 1,
"tools": {
"builtins": [
"web_search",
{
"name": "web_search_cfg",
"api_key": "pplx-test",
},
],
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
assert len(spec.tools.builtins) == 2
# First entry: string → no config.
assert spec.tools.builtins[0].name == "web_search"
assert spec.tools.builtins[0].config == {}
# Second entry: dict → has config.
assert spec.tools.builtins[1].name == "web_search_cfg"
assert spec.tools.builtins[1].config == {"api_key": "pplx-test"}
def test_parse_builtins_dict_missing_name(tmp_path: Path) -> None:
"""Dict entry without 'name' raises OmnigentError."""
config = {
"spec_version": 1,
"tools": {
"builtins": [
{"api_key": "orphan-key"},
],
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
with pytest.raises(OmnigentError, match=r"name"):
parse(tmp_path)
def test_parse_executor_config(tmp_path: Path) -> None:
"""Executor block with explicit timeout and max_iterations."""
config = {
"spec_version": 1,
"executor": {
"timeout": 7200,
"max_iterations": 500,
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
# Explicit executor timeout should be honored.
# Failure means executor block parsing is broken.
assert spec.executor.timeout == 7200
# Explicit max_iterations should override the 1000 default.
# Failure means max_iterations is ignored by the parser.
assert spec.executor.max_iterations == 500
# Default type should be "omnigent" when not specified.
# Failure means the parser doesn't apply the default type.
assert spec.executor.type == "omnigent"
def test_parse_executor_defaults(tmp_path: Path) -> None:
"""No executor block yields ExecutorSpec defaults."""
config = {"spec_version": 1}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
# Default executor timeout is 3600s per ExecutorSpec.
# Failure means the parser uses a different default.
assert spec.executor.timeout == 3600
# Default max_iterations is 1000 per ExecutorSpec.
# Failure means the parser uses a different default.
assert spec.executor.max_iterations == 1000
# Default type is "omnigent" per ExecutorSpec.
# Failure means the parser uses a different default.
assert spec.executor.type == "omnigent"
@pytest.mark.parametrize(
("config", "match"),
[
(
{"llm": {"model": "openai/gpt-4o", "request_timeout": True}},
r"llm\.request_timeout must be an integer",
),
(
{"tools": {"timeout": False}},
r"tools\.timeout must be an integer",
),
(
{"llm": {"model": "openai/gpt-4o", "retry": {"max_retries": True}}},
r"retry\.max_retries must be an integer",
),
(
{"llm": {"model": "openai/gpt-4o", "retry": {"backoff_base_s": False}}},
r"retry\.backoff_base_s must be a number",
),
(
{
"llm": {
"model": "openai/gpt-4o",
"retry": {"retryable_status_codes": [429, True]},
}
},
r"retry\.retryable_status_codes must be an integer",
),
(
{"executor": {"timeout": True}},
r"executor\.timeout must be an integer",
),
(
{"executor": {"max_iterations": False}},
r"executor\.max_iterations must be an integer",
),
(
{"executor": {"context_window": True}},
r"executor\.context_window must be an integer",
),
(
{"compaction": {"recent_window": False}},
r"compaction\.recent_window must be an integer",
),
(
{"compaction": {"trigger_threshold": True}},
r"compaction\.trigger_threshold must be a number",
),
(
{"guardrails": {"ask_timeout": True}},
r"guardrails\.ask_timeout must be an integer",
),
],
)
def test_parse_rejects_boolean_values_for_numeric_config_fields(
tmp_path: Path,
config: dict[str, object],
match: str,
) -> None:
"""Boolean YAML values must not be accepted as numeric config."""
config = {"spec_version": 1, **config}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
with pytest.raises(OmnigentError, match=match):
parse(tmp_path)
def test_parse_rejects_boolean_terminal_scrollback(tmp_path: Path) -> None:
"""Terminal scrollback is a line count, not a boolean flag."""
config = {
"spec_version": 1,
"terminals": {
"main": {
"command": "bash",
"scrollback": False,
},
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
with pytest.raises(OmnigentError, match=r"terminals\.main\.scrollback must be an integer"):
parse(tmp_path)
def test_parse_executor_config_field(tmp_path: Path) -> None:
"""Executor block with a ``config`` sub-block parses string values.
The ``config`` field is executor-type-specific. For the omnigent
executor it carries ``harness`` / ``profile``. The parser coerces
values to strings so non-string YAML scalars (numbers, bools)
round-trip as their string form.
"""
config = {
"spec_version": 1,
"executor": {
"type": "omnigent",
"config": {
"harness": "claude-sdk",
"profile": "test-profile",
},
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
# Failure means the parser silently drops the config block,
# breaking omnigent harness selection at executor construction.
assert spec.executor.type == "omnigent"
assert spec.executor.config == {
"harness": "claude-sdk",
"profile": "test-profile",
}
def test_parse_executor_config_missing_defaults_to_empty(
tmp_path: Path,
) -> None:
"""Absent ``executor.config`` block yields an empty dict, not None."""
config = {"spec_version": 1, "executor": {"type": "omnigent"}}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
# Failure means callers that do ``spec.executor.config.get(...)``
# would hit AttributeError on None.
assert spec.executor.config == {}
def test_parse_mcp_server_with_timeout_and_retry(
agent_dir: Path,
) -> None:
"""MCP server YAML with per-server timeout and retry overrides."""
mcp_dir = agent_dir / "tools" / "mcp"
mcp_dir.mkdir(parents=True)
mcp_config = {
"name": "slow-service",
"transport": "http",
"url": "http://localhost:9000/mcp",
"timeout": 120,
"retry": {
"max_retries": 7,
"backoff_base_s": 3.0,
},
}
(mcp_dir / "slow.yaml").write_text(yaml.dump(mcp_config))
spec = parse(agent_dir)
assert len(spec.mcp_servers) == 1
mcp = spec.mcp_servers[0]
# Per-server timeout should be parsed from the YAML.
# Failure means MCP timeout parsing is broken (returns None).
assert mcp.timeout == 120
# Per-server retry should be populated, not None.
# Failure means the retry block is ignored for MCP servers.
assert mcp.retry is not None
# Retry max_retries should match the YAML value.
# Failure means MCP retry fields are not forwarded correctly.
assert mcp.retry.max_retries == 7
def test_parse_rejects_boolean_mcp_timeout(agent_dir: Path) -> None:
"""MCP timeout is a duration in seconds, not a boolean flag."""
mcp_dir = agent_dir / "tools" / "mcp"
mcp_dir.mkdir(parents=True)
mcp_config = {
"name": "slow-service",
"transport": "http",
"url": "http://localhost:9000/mcp",
"timeout": True,
}
(mcp_dir / "slow.yaml").write_text(yaml.dump(mcp_config))
with pytest.raises(OmnigentError, match=r"MCP server 'slow-service'\.timeout"):
parse(agent_dir)
def test_parse_mcp_stdio_minimal(agent_dir: Path) -> None:
"""
Parse a stdio MCP server with only the required ``command``.
What breaks if this fails: authors declaring a subprocess MCP
(the common glean / github / databricks shape) would see the
parser reject the whole spec at load time.
:param agent_dir: Temporary agent directory fixture.
"""
mcp_dir = agent_dir / "tools" / "mcp"
mcp_dir.mkdir(parents=True)
mcp_config = {
"name": "local-tool",
"transport": "stdio",
"command": "npx",
}
(mcp_dir / "local.yaml").write_text(yaml.dump(mcp_config))
spec = parse(agent_dir)
mcp = spec.mcp_servers[0]
# Transport + command survive the parse.
assert mcp.transport == "stdio"
assert mcp.command == "npx"
# Defaults: empty args / env. The legacy ``sandbox: bool``
# field was removed in step 7; the parse should still work
# without it.
assert mcp.args == []
assert mcp.env == {}
# HTTP fields unset on stdio.
assert mcp.url is None
assert mcp.headers == {}
def test_parse_mcp_stdio_with_args_and_env(
agent_dir: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Parse a stdio MCP with every field populated, including
``${VAR}`` expansion in ``env``.
What breaks if this fails: a YAML like the databricks /
github MCPs (``env: {GITHUB_TOKEN: ${GITHUB_TOKEN}}``) would
either pass the literal ``${GITHUB_TOKEN}`` to the subprocess
(breaking auth) or fail at parse time.
:param agent_dir: Temporary agent directory fixture.
:param monkeypatch: Pytest monkeypatch for env vars.
"""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_xyz")
mcp_dir = agent_dir / "tools" / "mcp"
mcp_dir.mkdir(parents=True)
mcp_config = {
"name": "github",
"transport": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {"GITHUB_TOKEN": "${GITHUB_TOKEN}"},
}
(mcp_dir / "github.yaml").write_text(yaml.dump(mcp_config))
spec = parse(agent_dir)
mcp = spec.mcp_servers[0]
assert mcp.transport == "stdio"
assert mcp.command == "npx"
# Args preserved verbatim, not expanded (they're a literal argv).
assert mcp.args == ["-y", "@modelcontextprotocol/server-github"]
# ${GITHUB_TOKEN} expanded via monkeypatch — the subprocess sees
# the real token, not the literal.
assert mcp.env == {"GITHUB_TOKEN": "ghp_xyz"}
def test_parse_mcp_stdio_rejects_legacy_sandbox_field(agent_dir: Path) -> None:
"""
A YAML that still declares ``sandbox: <bool>`` on a stdio MCP
is rejected with a clear migration message.
Step 7 of the harness contract migration removed the
``MCPServerConfig.sandbox`` field. The previous default
(srt-wrap) blocked outbound network and silently broke
every useful MCP. Failing loud at parse time tells users
porting old YAMLs to drop the key, instead of silently
accepting it as a no-op.
What breaks if this fails: anyone copying a pre-step-7
MCP YAML gets a confusing "tool not found" or hang at
runtime; this test ensures the parser produces a
direct, actionable error instead.
"""
mcp_dir = agent_dir / "tools" / "mcp"
mcp_dir.mkdir(parents=True)
mcp_config = {
"name": "legacy",
"transport": "stdio",
"command": "npx",
"sandbox": False,
}
(mcp_dir / "legacy.yaml").write_text(yaml.dump(mcp_config))
with pytest.raises(OmnigentError, match=r"sandbox.*was removed"):
parse(agent_dir)
def test_parse_mcp_stdio_missing_command_raises(agent_dir: Path) -> None:
"""
Stdio MCP without ``command`` fails loud at parse time.
What breaks if this fails: authors who typo ``command:`` would
get a runtime AttributeError at MCP connection time instead of
a clean parse error naming the missing field.
:param agent_dir: Temporary agent directory fixture.
"""
mcp_dir = agent_dir / "tools" / "mcp"
mcp_dir.mkdir(parents=True)
(mcp_dir / "broken.yaml").write_text(yaml.dump({"name": "broken", "transport": "stdio"}))
with pytest.raises(OmnigentError, match=r"missing required field 'command'"):
parse(agent_dir)
def test_parse_mcp_stdio_rejects_http_fields(agent_dir: Path) -> None:
"""
Stdio MCP with a stray ``url:`` (copy-pasted from an HTTP
example) fails loud at parse time instead of silently ignoring
the wrong-transport field.
What breaks if this fails: authors migrating between
transports see their changes silently ignored — e.g. an HTTP
config edited to stdio but still carrying ``url`` looks fine
but doesn't actually use the URL.
:param agent_dir: Temporary agent directory fixture.
"""
mcp_dir = agent_dir / "tools" / "mcp"
mcp_dir.mkdir(parents=True)
(mcp_dir / "mixed.yaml").write_text(
yaml.dump(
{
"name": "mixed",
"transport": "stdio",
"command": "npx",
"url": "http://stale.example/sse",
}
)
)
with pytest.raises(OmnigentError, match=r"wrong-transport field"):
parse(agent_dir)
def test_parse_mcp_http_rejects_stdio_fields(agent_dir: Path) -> None:
"""
HTTP MCP with a stray ``command:`` fails loud at parse time.
Mirror of the stdio-rejects-HTTP test. Symmetric coverage so
either direction of mistaken transport mixing is caught.
:param agent_dir: Temporary agent directory fixture.
"""
mcp_dir = agent_dir / "tools" / "mcp"
mcp_dir.mkdir(parents=True)
(mcp_dir / "mixed.yaml").write_text(
yaml.dump(
{
"name": "mixed",
"transport": "http",
"url": "http://mcp.example.com/sse",
"command": "npx",
}
)
)
with pytest.raises(OmnigentError, match=r"wrong-transport field"):
parse(agent_dir)
def test_parse_mcp_unknown_transport_raises(agent_dir: Path) -> None:
"""
``transport: grpc`` or any other value fails loud with a
clear "must be 'http' or 'stdio'" message.
What breaks if this fails: a typo like ``stdin`` (instead of
``stdio``) would produce a cryptic downstream error
("AttributeError: 'NoneType' object has no attribute
'rstrip'" or similar) instead of naming the field.
:param agent_dir: Temporary agent directory fixture.
"""
mcp_dir = agent_dir / "tools" / "mcp"
mcp_dir.mkdir(parents=True)
(mcp_dir / "weird.yaml").write_text(
yaml.dump(
{
"name": "weird",
"transport": "grpc",
"command": "something",
}
)
)
with pytest.raises(OmnigentError, match=r"must be 'http' or 'stdio'"):
parse(agent_dir)
# ─── Top-level ``timers:`` flag (step 10 of harness contract) ─
def test_parse_timers_defaults_to_false_when_omitted(agent_dir: Path) -> None:
"""
Without a top-level ``timers:`` key the parsed ``AgentSpec.timers``
is ``False``.
Default-off matches the inner stack (``AgentDef.timers`` is also
``False`` by default) — agents authored before step 10 must keep
their pre-step-10 tool surface unchanged. A regression that
flipped the default to ``True`` would silently expose the timer
builtins to every agent.
:param agent_dir: Temporary agent directory fixture.
"""
spec = parse(agent_dir)
assert spec.timers is False
def test_parse_timers_true_sets_flag(tmp_path: Path) -> None:
"""
``timers: true`` in config.yaml round-trips to
``AgentSpec.timers == True``.
The flag is the gate for ``ToolManager._register_timer_tools``
(see step 10) — a regression where the parser dropped the
field would mean the YAML opt-in had no effect at runtime.
:param tmp_path: pytest-provided temporary directory.
"""
config = {"spec_version": 1, "name": "timer-agent", "timers": True}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
assert spec.timers is True
# ─── Top-level ``spawn:`` flag (spawn-write opt-in) ───────────
def test_parse_spawn_defaults_to_false_when_omitted(agent_dir: Path) -> None:
"""
Without a top-level ``spawn:`` key the parsed ``AgentSpec.spawn``
is ``False``.
Default-off is the design: session *reads* are always available,
but the child-session spawn writes (``sys_session_create`` /
``sys_session_send`` / ``sys_session_close``) are opt-in. A
regression that flipped the default to ``True`` would silently
expose the spawn-write surface to every agent.
:param agent_dir: Temporary agent directory fixture.
"""
spec = parse(agent_dir)
assert spec.spawn is False
def test_parse_spawn_true_sets_flag(tmp_path: Path) -> None:
"""
``spawn: true`` in config.yaml round-trips to
``AgentSpec.spawn == True``.
The flag is the sole grant for ``sys_session_create`` in
``ToolManager._register_sub_agent_tools`` (``tools.agents`` only
permits the declared sub-agent list via send/close) — a regression
where the parser dropped the field would mean the YAML opt-in had
no effect at runtime and the agent couldn't author-and-launch
child sessions.
:param tmp_path: pytest-provided temporary directory.
"""
config = {"spec_version": 1, "name": "spawn-agent", "spawn": True}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
assert spec.spawn is True
def test_parse_share_defaults_to_none_when_omitted(agent_dir: Path) -> None:
"""
Without a top-level ``agent_session_sharing:`` key the parsed
``AgentSpec.agent_session_sharing`` is :attr:`SharePolicy.NONE` —
sharing is off by default, so ``sys_session_share`` is not
registered. A regression flipping the default would expose the
access-control mutation (incl. ``__public__``) to every agent.
:param agent_dir: Temporary agent directory fixture.
"""
spec = parse(agent_dir)
assert spec.agent_session_sharing is SharePolicy.NONE
@pytest.mark.parametrize(
"value,expected",
[
("none", SharePolicy.NONE),
("non-public", SharePolicy.NON_PUBLIC),
("public", SharePolicy.PUBLIC),
],
)
def test_parse_share_maps_each_policy_string(
tmp_path: Path,
value: str,
expected: SharePolicy,
) -> None:
"""
Each recognized ``agent_session_sharing:`` string round-trips to its
:class:`SharePolicy` member. The flag is the sole enabler of
``sys_session_share`` (and ``public`` of the ``__public__`` tier);
a parser regression dropping or mismapping it would silently change
what the agent is allowed to expose.
:param tmp_path: pytest-provided temporary directory.
:param value: The YAML ``agent_session_sharing:`` string under test.
:param expected: The :class:`SharePolicy` it must parse to.
"""
config = {"spec_version": 1, "name": "share-agent", "agent_session_sharing": value}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
assert spec.agent_session_sharing is expected
def test_parse_share_invalid_value_fails_loud(tmp_path: Path) -> None:
"""
An unrecognized ``agent_session_sharing:`` value (here a plausible
typo) raises rather than silently disabling sharing — fail-loud, so
a misconfigured capability surfaces at parse time instead of becoming
a confusing "the tool isn't there" at runtime.
:param tmp_path: pytest-provided temporary directory.
"""
config = {"spec_version": 1, "name": "bad-share", "agent_session_sharing": "private"}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
with pytest.raises(OmnigentError, match="agent_session_sharing"):
parse(tmp_path)
# ---------------------------------------------------------------------------
# os_env.sandbox.env_passthrough
# ---------------------------------------------------------------------------
def test_parse_os_env_sandbox_env_passthrough_default_none(tmp_path: Path) -> None:
"""
Omitting ``env_passthrough`` parses to ``None``, which the helper
spawn path treats as "only the always-passed defaults".
Pinning the default here ensures that future spec changes don't
silently flip the helper to "inherit everything from the parent",
which would re-open the credential-leak vector
:func:`omnigent.inner.os_env.build_helper_env` is meant to close.
"""
config = {
"spec_version": 1,
"name": "no-passthrough",
"os_env": {
"type": "caller_process",
"sandbox": {"type": "linux_bwrap"},
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
assert spec.os_env is not None and spec.os_env.sandbox is not None
assert spec.os_env.sandbox.env_passthrough is None
def test_parse_os_env_sandbox_env_passthrough_explicit_list(tmp_path: Path) -> None:
"""
A list of valid POSIX env-var names round-trips verbatim.
This is the supported way for spec authors to grant the helper
access to specific credentials the agent legitimately uses
(e.g. an ``AWS_PROFILE`` to pick the right account, a
``GITHUB_TOKEN`` for git operations).
"""
config = {
"spec_version": 1,
"name": "with-passthrough",
"os_env": {
"type": "caller_process",
"sandbox": {
"type": "linux_bwrap",
"env_passthrough": ["AWS_PROFILE", "GITHUB_TOKEN", "DATABRICKS_HOST"],
},
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
assert spec.os_env is not None and spec.os_env.sandbox is not None
assert spec.os_env.sandbox.env_passthrough == [
"AWS_PROFILE",
"GITHUB_TOKEN",
"DATABRICKS_HOST",
]
def test_parse_os_env_sandbox_env_passthrough_empty_list_preserved(
tmp_path: Path,
) -> None:
"""
An explicit empty list parses to ``[]``, distinct from ``None``.
The helper spawn path treats both as "only defaults" today, but
keeping them distinct preserves the option to change one of them
(e.g. add a future "warn if user wrote an empty list explicitly"
diagnostic).
"""
config = {
"spec_version": 1,
"name": "empty-passthrough",
"os_env": {
"type": "caller_process",
"sandbox": {"type": "linux_bwrap", "env_passthrough": []},
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
assert spec.os_env is not None and spec.os_env.sandbox is not None
assert spec.os_env.sandbox.env_passthrough == []
@pytest.mark.parametrize(
"bad_value,match_regex",
[
("AWS_PROFILE", r"must be a list"),
(["AWS_PROFILE", 7], r"must be strings"),
(["AWS_PROFILE", ""], r"must be POSIX environment"),
(["1AWS_PROFILE"], r"must be POSIX environment"),
(["AWS PROFILE"], r"must be POSIX environment"),
(["AWS-PROFILE"], r"must be POSIX environment"),
(["AWS=PROFILE"], r"must be POSIX environment"),
(["AWS/PROFILE"], r"must be POSIX environment"),
],
ids=[
"scalar_string",
"non_string_entry",
"empty_string",
"starts_with_digit",
"contains_space",
"contains_dash",
"contains_equals",
"contains_slash",
],
)
def test_parse_os_env_sandbox_env_passthrough_validation(
tmp_path: Path, bad_value: object, match_regex: str
) -> None:
"""
Invalid ``env_passthrough`` values raise :class:`OmnigentError`
at parse time with a message that names the field and the rule
the entry violated.
The POSIX env-var name regex is the only thing standing between a
misconfigured spec and a name like ``AWS=secret`` smuggling a
*value* through the *name* slot of ``os.execve``. Fail loud here.
"""
config = {
"spec_version": 1,
"name": "bad-passthrough",
"os_env": {
"type": "caller_process",
"sandbox": {"type": "linux_bwrap", "env_passthrough": bad_value},
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
with pytest.raises(OmnigentError, match=match_regex):
parse(tmp_path)
# ---------------------------------------------------------------------------
# os_env.start_in_scratch
# ---------------------------------------------------------------------------
def test_parse_os_env_start_in_scratch_defaults_false(tmp_path: Path) -> None:
"""
Omitting ``start_in_scratch`` parses to ``False`` so existing
specs keep the long-standing behaviour of starting the helper in
cwd. Pinning the default here ensures a future YAML-shape tweak
(e.g. flipping the default) is caught at review time rather than
silently rerouting every agent's working directory into scratch.
"""
config = {
"spec_version": 1,
"name": "no-start-in-scratch",
"os_env": {
"type": "caller_process",
"sandbox": {"type": "linux_bwrap"},
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
assert spec.os_env is not None
assert spec.os_env.start_in_scratch is False
def test_parse_os_env_start_in_scratch_true(tmp_path: Path) -> None:
"""
Setting ``start_in_scratch: true`` together with an active
sandbox parses successfully and threads through to the spec.
"""
config = {
"spec_version": 1,
"name": "scratch-cwd",
"os_env": {
"type": "caller_process",
"sandbox": {"type": "linux_bwrap"},
"start_in_scratch": True,
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
assert spec.os_env is not None
assert spec.os_env.start_in_scratch is True
def test_parse_os_env_start_in_scratch_with_fork_rejected(tmp_path: Path) -> None:
"""
``start_in_scratch`` and ``fork`` both manage the agent's writable
workspace and would fight each other, so the parser rejects the
combination at spec-load time.
What breaks if this fails: a misconfigured spec with both knobs
set ships a helper whose effective cwd silently depends on which
setup step ran last — exactly the kind of "where did my files
go?" footgun the explicit error is meant to prevent.
"""
config = {
"spec_version": 1,
"name": "scratch-and-fork",
"os_env": {
"type": "caller_process",
"sandbox": {"type": "linux_bwrap"},
"fork": True,
"start_in_scratch": True,
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
with pytest.raises(OmnigentError, match=r"mutually exclusive"):
parse(tmp_path)
def test_parse_os_env_start_in_scratch_with_sandbox_none_rejected(
tmp_path: Path,
) -> None:
"""
``start_in_scratch`` requires an active sandbox because the
scratch tmpdir is created by the sandbox layer. Asking for
``sandbox.type: none`` together with ``start_in_scratch: true``
has no destination directory and is rejected at parse time so
users see the misconfiguration immediately rather than at first
tool call.
"""
config = {
"spec_version": 1,
"name": "scratch-without-sandbox",
"os_env": {
"type": "caller_process",
"sandbox": {"type": "none"},
"start_in_scratch": True,
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
with pytest.raises(OmnigentError, match=r"requires an active sandbox"):
parse(tmp_path)
def test_executor_profile_field_lifted_from_yaml(tmp_path: Path) -> None:
"""
Top-level ``executor.profile`` lifts into the concrete
``ExecutorSpec.profile`` field.
For ``executor.type == "omnigent"`` the parser additionally
mirrors the value into ``executor.config["profile"]`` so the
legacy reader (which still consults ``config["profile"]``)
keeps working until the omnigent-compat sunset lands.
:param tmp_path: pytest-provided temporary directory.
"""
config = {
"spec_version": 1,
"name": "agent",
"executor": {
"type": "omnigent",
"profile": "dev",
"config": {"harness": "claude-sdk"},
},
"llm": {"model": "databricks-claude-sonnet-4-6"},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
# Concrete field populated for every executor type.
assert spec.executor.profile == "dev"
# Back-compat mirror into config["profile"] for omnigent.
# Without this the legacy omnigent executor (which still
# reads config["profile"]) would silently fall back to env
# vars / DEFAULT section.
assert spec.executor.config.get("profile") == "dev"
def test_executor_profile_field_lifted_for_non_omnigent(tmp_path: Path) -> None:
"""``executor.profile`` lifts into ``ExecutorSpec.profile`` for all executor types."""
config = {
"spec_version": 1,
"name": "agent",
"executor": {"type": "claude_sdk", "profile": "prod"},
"llm": {"model": "databricks-claude-sonnet-4-6"},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
assert spec.executor.profile == "prod"
assert "profile" not in spec.executor.config
def test_omnigent_and_default_executor_minimal_configs_still_parse(tmp_path: Path) -> None:
"""Both legacy ``omnigent`` and default minimal YAMLs continue to parse cleanly."""
omni_config = {
"spec_version": 1,
"name": "omni-agent",
"executor": {
"type": "omnigent",
"config": {"harness": "claude-sdk"},
},
"llm": {"model": "databricks-claude-sonnet-4-6"},
}
omni_dir = tmp_path / "omni"
omni_dir.mkdir()
(omni_dir / "config.yaml").write_text(yaml.dump(omni_config))
omni_spec = parse(omni_dir)
assert omni_spec.executor.type == "omnigent"
assert omni_spec.executor.config.get("harness") == "claude-sdk"
llm_config = {
"spec_version": 1,
"name": "llm-agent",
"llm": {
"model": "openai/gpt-4o",
"connection": {"api_key": "sk-test"},
},
}
llm_dir = tmp_path / "llm"
llm_dir.mkdir()
(llm_dir / "config.yaml").write_text(yaml.dump(llm_config))
llm_spec = parse(llm_dir)
assert llm_spec.executor.type == "omnigent"
assert llm_spec.executor.profile is None
# ---------------------------------------------------------------------------
# executor.auth parsing
# ---------------------------------------------------------------------------
def test_parse_executor_auth_databricks(tmp_path: Path) -> None:
"""
``executor.auth: {type: databricks, profile: oss}`` parses into
:class:`DatabricksAuth`.
Failure means Databricks profile auth from the spec is silently
dropped and the harness falls back to env-var auth, which makes
the spec non-self-contained.
"""
config = {
"spec_version": 1,
"executor": {
"harness": "openai-agents",
"model": "databricks-gpt-5-4-mini",
"auth": {"type": "databricks", "profile": "oss"},
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
# auth field must be populated with a DatabricksAuth instance.
assert isinstance(spec.executor.auth, DatabricksAuth)
assert spec.executor.auth.profile == "oss"
def test_parse_executor_auth_api_key(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""
``executor.auth: {type: api_key, api_key: $MY_KEY}`` expands the
env-var reference and parses into :class:`ApiKeyAuth`.
Failure means the api_key is not expanded at parse time and the
executor receives a literal ``$MY_KEY`` string instead of the real
key value.
"""
monkeypatch.setenv("MY_KEY", "sk-test-123")
config = {
"spec_version": 1,
"executor": {
"harness": "openai-agents",
"model": "gpt-4o",
"auth": {"type": "api_key", "api_key": "$MY_KEY"},
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
# auth must be ApiKeyAuth with the resolved key value.
assert isinstance(spec.executor.auth, ApiKeyAuth)
assert spec.executor.auth.api_key == "sk-test-123"
def test_parse_executor_auth_provider(tmp_path: Path) -> None:
"""
``executor.auth: {type: provider, name: litellm}`` parses into
:class:`ProviderAuth` carrying the provider name.
Failure means a spec referencing a named generic provider is dropped
and the harness never routes through that provider's endpoint.
"""
config = {
"spec_version": 1,
"executor": {
"harness": "codex",
"auth": {"type": "provider", "name": "litellm"},
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
# auth must be a ProviderAuth carrying the provider name verbatim.
# If this returned ApiKeyAuth/None instead, the provider branch in
# _parse_executor_auth was not taken and routing would fall back.
assert isinstance(spec.executor.auth, ProviderAuth)
assert spec.executor.auth.name == "litellm"
assert spec.executor.auth.type == "provider"
def test_parse_executor_auth_provider_missing_name_raises(tmp_path: Path) -> None:
"""``type: provider`` without a ``name`` fails loud, not silently empty."""
config = {
"spec_version": 1,
"executor": {"auth": {"type": "provider"}},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
with pytest.raises(OmnigentError, match=r"executor.auth.name is required"):
parse(tmp_path)
def test_parse_executor_auth_absent(tmp_path: Path) -> None:
"""No ``auth:`` key yields ``spec.executor.auth is None``."""
config = {
"spec_version": 1,
"executor": {"harness": "openai-agents", "model": "gpt-4o"},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
# auth must be None — harness falls back to env-var / profile defaults.
assert spec.executor.auth is None
def test_parse_executor_auth_unknown_type_raises(tmp_path: Path) -> None:
"""An unknown ``auth.type`` value raises :class:`OmnigentError`."""
config = {
"spec_version": 1,
"executor": {"auth": {"type": "magic_token", "token": "abc"}},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
with pytest.raises(OmnigentError, match=r"must be 'api_key', 'databricks', or 'provider'"):
parse(tmp_path)
def test_parse_executor_auth_api_key_missing_key_raises(tmp_path: Path) -> None:
"""
``type: api_key`` without an ``api_key`` field raises
:class:`OmnigentError` rather than producing an empty key.
"""
config = {
"spec_version": 1,
"executor": {"auth": {"type": "api_key"}},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
with pytest.raises(OmnigentError, match=r"api_key is required"):
parse(tmp_path)
def test_parse_executor_auth_databricks_missing_profile_raises(tmp_path: Path) -> None:
"""
``type: databricks`` without a ``profile`` field raises
:class:`OmnigentError` rather than silently using an empty profile.
"""
config = {
"spec_version": 1,
"executor": {"auth": {"type": "databricks"}},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
with pytest.raises(OmnigentError, match=r"profile is required"):
parse(tmp_path)
def test_parse_executor_auth_api_key_with_base_url(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""
``executor.auth: {type: api_key, api_key: …, base_url: …}`` parses
both fields and expands env-var references in ``base_url``.
Failure means a custom endpoint declared alongside an API key is
silently dropped, routing requests to the default OpenAI endpoint.
"""
monkeypatch.setenv("MY_KEY", "sk-test-456")
monkeypatch.setenv("MY_BASE", "https://gw.example.com/v1")
config = {
"spec_version": 1,
"executor": {
"harness": "openai-agents",
"auth": {"type": "api_key", "api_key": "$MY_KEY", "base_url": "$MY_BASE"},
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
assert isinstance(spec.executor.auth, ApiKeyAuth)
assert spec.executor.auth.api_key == "sk-test-456"
assert spec.executor.auth.base_url == "https://gw.example.com/v1"
def test_parse_executor_auth_api_key_base_url_absent(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""``base_url`` defaults to ``None`` when not declared."""
monkeypatch.setenv("MY_KEY", "sk-no-url")
config = {
"spec_version": 1,
"executor": {
"harness": "openai-agents",
"auth": {"type": "api_key", "api_key": "$MY_KEY"},
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
assert isinstance(spec.executor.auth, ApiKeyAuth)
assert spec.executor.auth.base_url is None
# ── credential_proxy parser tests ─────────────────────────────────
def _credential_proxy_config(entries: list[dict[str, object]]) -> dict[str, object]:
"""
Build a minimal agent config carrying a ``credential_proxy`` block.
:param entries: The ``credential_proxy`` list to embed under
``os_env.sandbox``.
:returns: A config dict ready to ``yaml.dump`` for :func:`parse`.
"""
return {
"spec_version": 1,
"name": "cred-proxy",
"os_env": {
"type": "caller_process",
"cwd": ".",
"sandbox": {
"type": "linux_bwrap",
"egress_rules": [
"* github.com/**",
"* api.github.com/**",
"* corp.example.com/**",
"* git.example.com/**",
"* bearer.example.com/**",
"* basic.example.com/**",
],
"credential_proxy": entries,
},
},
}
def test_parse_credential_proxy_all_four_types(tmp_path: Path) -> None:
"""All four ``credential_proxy`` types normalize to host bindings.
What breaks if this fails: the YAML the user writes wouldn't reach
the runtime as the right per-host scheme/injection, so the egress
proxy wouldn't swap credentials (or would swap the wrong scheme).
"""
config = _credential_proxy_config(
[
{"type": "gh_basic", "source": {"env": "GH_PAT"}},
{
"type": "git_https",
"target": "git.example.com/org/repo.git",
"source": {"env": "GH_PAT"},
},
{
"type": "https_bearer",
"target": "bearer.example.com/rest",
"source": {"env": "CORP"},
"env": "CORP_TOKEN",
},
{
"type": "https_basic",
"targets": ["basic.example.com"],
"source": {"file": "/tmp/secret"},
"username": "svc",
},
]
)
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
proxy = spec.os_env.sandbox.credential_proxy
assert proxy is not None
by = {(e.host, e.scheme): e for e in proxy.entries}
# gh_basic -> github.com (basic, swap-on-access) and api.github.com
# (token + GH_TOKEN/GITHUB_TOKEN injection because gh gates locally).
gh_git = by[("github.com", "basic")]
assert gh_git.inject_env == [] # swap-on-access: nothing injected for git
gh_api = by[("api.github.com", "token")]
assert gh_api.inject_env == ["GH_TOKEN", "GITHUB_TOKEN"]
# git_https -> swap-on-access Basic for its bound host (nothing injected).
git_https = by[("git.example.com", "basic")]
assert git_https.inject_env == []
# https_bearer with an explicit ``env`` opts into placeholder injection.
bearer = by[("bearer.example.com", "bearer")]
assert bearer.inject_env == ["CORP_TOKEN"]
assert bearer.source.kind == "env" and bearer.source.env == "CORP"
# https_basic keeps the explicit username and a file source; with no
# ``env`` it is pure swap-on-access (inject_env empty).
basic = by[("basic.example.com", "basic")]
assert basic.username == "svc"
assert basic.inject_env == []
assert basic.source.kind == "file" and basic.source.path == "/tmp/secret"
def test_parse_credential_proxy_rejects_duplicate_host(tmp_path: Path) -> None:
"""Two entries binding the same host fail loudly at parse time.
The egress proxy keys its swap-on-access table by host, so a
duplicate-host config would silently drop one credential (last
wins). Rejecting it up front prevents a nondeterministic,
hard-to-debug "wrong scheme on the wire" outcome. Here ``gh_basic``
already binds ``github.com`` and the explicit ``git_https`` binds it
again.
"""
config = _credential_proxy_config(
[
{"type": "gh_basic", "source": {"env": "GH_PAT"}},
{"type": "git_https", "target": "github.com", "source": {"env": "GH_PAT"}},
]
)
(tmp_path / "config.yaml").write_text(yaml.dump(config))
with pytest.raises(OmnigentError, match=r"binds host 'github.com' more than once"):
parse(tmp_path)
def test_parse_credential_proxy_git_https_default_username(tmp_path: Path) -> None:
"""``git_https`` defaults the Basic username to ``x-access-token``.
A wrong default would make GitHub reject the Basic auth even though
the token is valid.
"""
config = _credential_proxy_config(
[{"type": "git_https", "target": "github.com", "source": {"env": "GH_PAT"}}]
)
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
entry = spec.os_env.sandbox.credential_proxy.entries[0]
assert entry.username == "x-access-token"
def test_parse_credential_proxy_https_env_optional(tmp_path: Path) -> None:
"""``https_*`` without ``env`` parses as a swap-on-access binding.
The ``env`` field is the opt-in injection shim, not a requirement.
Omitting it must yield a valid entry with an empty ``inject_env`` so
the proxy attaches the credential on access. If ``env`` were still
treated as required, parsing would raise instead.
"""
config = _credential_proxy_config(
[{"type": "https_bearer", "target": "corp.example.com", "source": {"env": "CORP"}}]
)
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
entry = spec.os_env.sandbox.credential_proxy.entries[0]
assert entry.host == "corp.example.com"
assert entry.scheme == "bearer"
assert entry.inject_env == []
@pytest.mark.parametrize(
"entries,match",
[
# Unknown ``type`` — caught by the pydantic ``Literal``.
([{"type": "bogus", "source": {"env": "X"}}], r"type: Input should be"),
# Missing ``source`` — pydantic ``Field required``.
([{"type": "https_bearer", "target": "h.example.com"}], r"source: Field required"),
# ``source`` as a bare string (the old surface) is now rejected —
# it must be a nested ``{env|file|command: ...}`` mapping.
(
[{"type": "https_bearer", "target": "h.example.com", "source": "env:X", "env": "T"}],
r"source:.*valid dictionary",
),
# Two source keys set — exactly one is allowed.
(
[
{
"type": "https_bearer",
"target": "h.example.com",
"source": {"env": "X", "file": "/tmp/s"},
}
],
r"exactly one of 'env', 'file', or 'command'",
),
# Malformed ``env`` injection-shim name.
(
[
{
"type": "https_bearer",
"target": "h.example.com",
"source": {"env": "X"},
"env": "not a valid name",
}
],
r"env must be a POSIX",
),
# Both ``target`` and ``targets`` set.
(
[
{
"type": "https_bearer",
"target": "h.example.com",
"targets": ["h2.example.com"],
"source": {"env": "X"},
"env": "T",
}
],
r"exactly one of 'target' or 'targets'",
),
# Host fails DNS-safety validation (still enforced post-pydantic).
(
[{"type": "git_https", "target": "bad_host!", "source": {"env": "X"}}],
r"must be an exact DNS hostname",
),
# Unknown key — ``extra="forbid"`` rejects typos.
(
[
{
"type": "https_bearer",
"target": "h.example.com",
"source": {"env": "X"},
"bogus": 1,
}
],
r"bogus: Extra inputs are not permitted",
),
],
)
def test_parse_credential_proxy_fail_loud(
tmp_path: Path, entries: list[dict[str, object]], match: str
) -> None:
"""Malformed ``credential_proxy`` entries fail loudly at parse time.
Each case proves a specific misconfiguration is rejected up front
rather than silently producing a half-wired (insecure) policy.
"""
config = _credential_proxy_config(entries)
(tmp_path / "config.yaml").write_text(yaml.dump(config))
with pytest.raises(OmnigentError, match=match):
parse(tmp_path)
def test_parse_credential_proxy_requires_egress_rules(tmp_path: Path) -> None:
"""``credential_proxy`` without ``egress_rules`` is rejected.
The MITM proxy (driven by egress_rules) is what performs the swap and
blocks placeholder leaks; without it the feature would be a no-op that
injects placeholders the agent can't use — fail loud instead.
"""
config = {
"spec_version": 1,
"name": "cred-proxy-no-egress",
"os_env": {
"type": "caller_process",
"cwd": ".",
"sandbox": {
"type": "linux_bwrap",
"credential_proxy": [
{"type": "git_https", "target": "github.com", "source": {"env": "X"}}
],
},
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
with pytest.raises(OmnigentError, match=r"requires os_env.sandbox.egress_rules"):
parse(tmp_path)
def test_parse_credential_proxy_requires_hard_backend(tmp_path: Path) -> None:
"""``credential_proxy`` requires a network-isolating backend.
On ``linux_landlock`` (no hard network deny) the egress proxy isn't
the only path out, so binding credentials there is unsafe — rejected.
We deliberately OMIT ``egress_rules`` here so the egress-rules backend
guard doesn't fire first: that isolates the credential_proxy-specific
backend check (parser.py:1117). The ``match`` asserts the
credential_proxy message, not the egress one — so deleting the
credential_proxy backend guard (falling through to the
"requires egress_rules" error with its different text) would fail
this test.
"""
config = {
"spec_version": 1,
"name": "cred-proxy-soft-backend",
"os_env": {
"type": "caller_process",
"cwd": ".",
"sandbox": {
"type": "linux_landlock",
"credential_proxy": [
{"type": "git_https", "target": "github.com", "source": {"env": "X"}}
],
},
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
with pytest.raises(OmnigentError, match=r"credential_proxy requires sandbox.type"):
parse(tmp_path)
def test_parse_credential_proxy_gh_basic_rejected_on_macos(tmp_path: Path) -> None:
"""``gh_basic`` is rejected on macOS (``darwin_seatbelt``).
``gh_basic`` wires the GitHub CLI, a Go binary, and Go on macOS verifies
TLS via the system keychain and ignores ``SSL_CERT_FILE`` — the var the
egress MITM proxy uses to publish its CA — so every ``gh`` call would fail
at runtime with an opaque ``certificate is not trusted`` error. We fail
loud at parse time instead. The ``match`` asserts the macOS-specific
message (not the backend/egress guards, which pass here since
``darwin_seatbelt`` + ``egress_rules`` are both present), so removing the
macOS guard would let the spec parse and fail this test.
"""
config = {
"spec_version": 1,
"name": "cred-proxy-gh-macos",
"os_env": {
"type": "caller_process",
"cwd": ".",
"sandbox": {
"type": "darwin_seatbelt",
"egress_rules": ["* github.com/**", "* api.github.com/**"],
"credential_proxy": [{"type": "gh_basic", "source": {"env": "GH_PAT"}}],
},
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
with pytest.raises(OmnigentError, match=r"gh_basic' does not work on macOS"):
parse(tmp_path)
def test_parse_credential_proxy_https_primitive_allowed_on_macos(tmp_path: Path) -> None:
"""The generic primitives are NOT rejected on macOS.
The macOS guard must fire ONLY for the Go-based ``gh_basic`` preset (the
``token`` scheme). ``https_bearer`` (and ``https_basic`` / ``git_https``)
are consumed by curl/python/git, which trust ``SSL_CERT_FILE`` on macOS, so
they must still parse on ``darwin_seatbelt``. This guards against the guard
being too broad and breaking the primitives that DO work.
"""
config = {
"spec_version": 1,
"name": "cred-proxy-bearer-macos",
"os_env": {
"type": "caller_process",
"cwd": ".",
"sandbox": {
"type": "darwin_seatbelt",
"egress_rules": ["* corp.example.com/**"],
"credential_proxy": [
{
"type": "https_bearer",
"target": "corp.example.com/rest",
"source": {"env": "CORP"},
"env": "CORP_TOKEN",
}
],
},
},
}
(tmp_path / "config.yaml").write_text(yaml.dump(config))
spec = parse(tmp_path)
proxy = spec.os_env.sandbox.credential_proxy
assert proxy is not None
assert proxy.entries[0].scheme == "bearer"