diff --git a/src/autoskillit/execution/backends/AGENTS.md b/src/autoskillit/execution/backends/AGENTS.md index 3679fee19..567b518d9 100644 --- a/src/autoskillit/execution/backends/AGENTS.md +++ b/src/autoskillit/execution/backends/AGENTS.md @@ -7,6 +7,7 @@ IL-1 backend abstraction layer — concrete `CodingAgentBackend` implementations | File | Purpose | |------|---------| | `__init__.py` | `BACKEND_REGISTRY`, `get_backend()` factory, re-exports | +| `_backend_cmd_builder_base.py` | `BackendCmdBuilderBase` ABC, `FlagVocabulary` NamedTuple — shared env assembly base class for all backends | | `claude.py` | `ClaudeCodeBackend` (incl. `validate_session_layout`), `ClaudeEnvPolicy`, `ClaudeSessionLocator`, `ClaudeStreamParser`, `ClaudeResultParser` (prompt utilities moved to `_claude_prompt.py`) | | `_claude_prompt.py` | Prompt injection utilities, session constants (`_ensure_skill_prefix`, `_inject_completion_directive`, `_compose_resume_prompt`, etc.), shared by claude + codex + commands | | `codex.py` | `CodexFlags`, `CodexBackend` (incl. `validate_session_layout`, `setup_session_dir` agent TOML generation via `_generate_agent_tomls`), `CodexEnvPolicy`, `CodexSessionLocator` (parse/config moved to `_codex_parse.py` / `_codex_config.py`) | diff --git a/src/autoskillit/execution/backends/_backend_cmd_builder_base.py b/src/autoskillit/execution/backends/_backend_cmd_builder_base.py new file mode 100644 index 000000000..c33d082ac --- /dev/null +++ b/src/autoskillit/execution/backends/_backend_cmd_builder_base.py @@ -0,0 +1,145 @@ +"""Shared base class and types for backend command builder implementations. + +Owns the eight env keys that every backend's skill-session and food-truck +cmd builders must inject (campaign id, kitchen session id, scenario step name, +allowed write prefixes, cwd, MCP token ceiling, MCP connection nonblocking). +Concrete backends (``ClaudeCodeBackend``, ``CodexBackend``) inherit from +:class:`BackendCmdBuilderBase` and contribute backend-specific extension +points (binary, sandbox default, env policy, flag vocabulary). + +This module is IL-1 — it imports from ``autoskillit.core`` and +same-package modules only. It must NOT import from ``claude.py`` or +``codex.py`` to avoid a cyclic import. +""" + +from __future__ import annotations + +import abc +import os +from dataclasses import asdict, dataclass +from typing import Any, NamedTuple + +from autoskillit.core import ( + CAMPAIGN_ID_ENV_VAR, + KITCHEN_SESSION_ID_ENV_VAR, + EnvPolicy, + SkillSessionConfig, +) +from autoskillit.execution.backends._claude_prompt import _MAX_MCP_OUTPUT_TOKENS_VALUE + + +class FlagVocabulary(NamedTuple): + """Per-backend CLI flag structure. + + Captures which flags a backend treats as variadic (may repeat with + distinct values, e.g. ``--add-dir``) vs non-variadic, plus the canonical + flag spellings for the four flags every backend shares (model, add-dir, + resume, config override). + """ + + variadic_flags: frozenset[str] + non_variadic_flags: frozenset[str] + model_flag: str + add_dir_flag: str + resume_flag: str + config_override_flag: str + + +@dataclass(frozen=True, slots=True) +class BackendCmdBuilderBase(abc.ABC): + """Abstract base for backend cmd builders. + + Concrete subclasses (e.g. ``ClaudeCodeBackend``) supply the four + extension points below and inherit the two concrete helpers. The base + is intentionally minimal — it owns ONLY the eight shared env keys and + the SkillSessionConfig unpacking helper. Backend-specific keys + (``AUTOSKILLIT_HEADLESS``, ``AUTOSKILLIT_SESSION_TYPE``, Claude's + ``CLAUDE_CODE_EXIT_AFTER_STOP_DELAY``, Codex's + ``AUTOSKILLIT_COMPLETION_MARKER``, etc.) remain in each caller's local + dict. + """ + + @property + @abc.abstractmethod + def _binary(self) -> str: + """Return the CLI binary name (e.g. ``"claude"`` or ``"codex"``).""" + + @property + @abc.abstractmethod + def _sandbox_default(self) -> str: + """Return the backend's default sandbox mode string. + + ``""`` for backends without a sandbox concept; ``"workspace-write"`` + for backends that default to workspace-write sandbox. + """ + + @property + @abc.abstractmethod + def _env_policy(self) -> EnvPolicy: + """Return the backend's env policy instance.""" + + @property + @abc.abstractmethod + def _flag_vocabulary(self) -> FlagVocabulary: + """Return the backend's flag vocabulary describing its CLI surface.""" + + def _assemble_shared_env_extras( + self, + *, + scenario_step_name: str = "", + allowed_write_prefix: str = "", + allowed_write_prefixes: tuple[str, ...] = (), + cwd: str = "", + ) -> dict[str, str]: + """Build the dict of env keys every backend injects. + + Always-set keys: + - ``MAX_MCP_OUTPUT_TOKENS`` -> ``_MAX_MCP_OUTPUT_TOKENS_VALUE`` + - ``MCP_CONNECTION_NONBLOCKING`` -> ``"0"`` + + Conditional keys (present only when their input is truthy or + present in ``os.environ``): + - ``CAMPAIGN_ID_ENV_VAR`` -> ``os.environ[...]`` + - ``KITCHEN_SESSION_ID_ENV_VAR`` -> ``os.environ[...]`` + - ``SCENARIO_STEP_NAME`` -> ``scenario_step_name`` + - ``AUTOSKILLIT_ALLOWED_WRITE_PREFIX`` -> ``allowed_write_prefix`` + - ``AUTOSKILLIT_ALLOWED_WRITE_PREFIXES`` -> colon-joined tuple + - ``AUTOSKILLIT_CWD`` -> ``cwd`` + + ``AUTOSKILLIT_SESSION_TYPE`` is intentionally NOT assembled here. + It is a backend-specific concern: claude.py writes it from a local + constant (``SESSION_TYPE_SKILL`` / ``SESSION_TYPE_ORCHESTRATOR``) + and codex.py writes it via ``_codex_exec_extras``. Including it as + a parameter here would be a structural trap for future callers. + """ + extras: dict[str, str] = { + "MAX_MCP_OUTPUT_TOKENS": _MAX_MCP_OUTPUT_TOKENS_VALUE, + "MCP_CONNECTION_NONBLOCKING": "0", + } + campaign_id = os.environ.get(CAMPAIGN_ID_ENV_VAR) + if campaign_id: + extras[CAMPAIGN_ID_ENV_VAR] = campaign_id + kitchen_session_id = os.environ.get(KITCHEN_SESSION_ID_ENV_VAR) + if kitchen_session_id: + extras[KITCHEN_SESSION_ID_ENV_VAR] = kitchen_session_id + if scenario_step_name: + extras["SCENARIO_STEP_NAME"] = scenario_step_name + if allowed_write_prefix: + extras["AUTOSKILLIT_ALLOWED_WRITE_PREFIX"] = allowed_write_prefix + if allowed_write_prefixes: + extras["AUTOSKILLIT_ALLOWED_WRITE_PREFIXES"] = ":".join(allowed_write_prefixes) + if cwd: + extras["AUTOSKILLIT_CWD"] = cwd + return extras + + def _apply_config(self, config: SkillSessionConfig) -> dict[str, Any]: + """Unpack a :class:`SkillSessionConfig` into a flat ``dict[str, Any]``. + + Round-trips all 18 fields of ``SkillSessionConfig``. Backends + consume this dict when forwarding fields into per-backend env vars + or CLI flags. + """ + return asdict(config) + + +__all__ = ["BackendCmdBuilderBase", "FlagVocabulary"] diff --git a/src/autoskillit/execution/backends/claude.py b/src/autoskillit/execution/backends/claude.py index b44ff1c54..ec0848665 100644 --- a/src/autoskillit/execution/backends/claude.py +++ b/src/autoskillit/execution/backends/claude.py @@ -15,14 +15,14 @@ AGENT_BACKEND_ENV_VAR, AUTOSKILLIT_APPLICABLE_GUARDS, AUTOSKILLIT_WRITE_GUARD_TOOL_NAMES, - CAMPAIGN_ID_ENV_VAR, CLAUDE_CODE_CAPABILITIES, CONTEXT_EXHAUSTION_MARKER, - KITCHEN_SESSION_ID_ENV_VAR, + NON_VARIADIC_CLAUDE_FLAGS, ORCHESTRATOR_SESSION_REQUIRED_ENV, SESSION_TYPE_ORCHESTRATOR, SESSION_TYPE_SKILL, SKILL_SESSION_REQUIRED_ENV, + VARIADIC_CLAUDE_FLAGS, AgentSessionResult, BackendCapabilities, BackendConventions, @@ -33,6 +33,7 @@ ClaudeFlags, CmdSpec, DirectInstall, + EnvPolicy, MarketplaceInstall, NamedResume, NoResume, @@ -53,11 +54,14 @@ load_yaml, pkg_root, ) +from autoskillit.execution.backends._backend_cmd_builder_base import ( + BackendCmdBuilderBase, + FlagVocabulary, +) from autoskillit.execution.backends._claude_prompt import ( _HEADLESS_ENV_HARDENING, _HEADLESS_EXCLUSIVE_VARS, _INTERACTIVE_ENV_EXCLUSIONS, - _MAX_MCP_OUTPUT_TOKENS_VALUE, _PROVIDER_EXTRAS_BASE_DENYLIST, _SESSION_BASELINE_ENV, _SKILL_SESSION_EXTRAS_DENYLIST, @@ -83,6 +87,16 @@ ] +CLAUDE_FLAG_VOCABULARY = FlagVocabulary( + variadic_flags=VARIADIC_CLAUDE_FLAGS, + non_variadic_flags=NON_VARIADIC_CLAUDE_FLAGS, + model_flag=ClaudeFlags.MODEL, + add_dir_flag=ClaudeFlags.ADD_DIR, + resume_flag=ClaudeFlags.RESUME, + config_override_flag="", +) + + @dataclass(frozen=True, slots=True) class ClaudeEnvPolicy: def build_env( @@ -270,7 +284,23 @@ def parse_stdout(self, stdout: str, *, exit_code: int = 0) -> AgentSessionResult @dataclass(frozen=True, slots=True) -class ClaudeCodeBackend: +class ClaudeCodeBackend(BackendCmdBuilderBase): + @property + def _binary(self) -> str: + return "claude" + + @property + def _sandbox_default(self) -> str: + return "" + + @property + def _env_policy(self) -> EnvPolicy: + return ClaudeEnvPolicy() + + @property + def _flag_vocabulary(self) -> FlagVocabulary: + return CLAUDE_FLAG_VOCABULARY + @property def name(self) -> str: return AGENT_BACKEND_CLAUDE_CODE @@ -600,8 +630,6 @@ def _build_skill_session_cmd_impl( extras: dict[str, str] = { "AUTOSKILLIT_HEADLESS": "1", "AUTOSKILLIT_SESSION_TYPE": SESSION_TYPE_SKILL, - "MAX_MCP_OUTPUT_TOKENS": _MAX_MCP_OUTPUT_TOKENS_VALUE, - "MCP_CONNECTION_NONBLOCKING": "0", AGENT_BACKEND_ENV_VAR: AGENT_BACKEND_CLAUDE_CODE, AGENT_BACKEND_DYNACONF_ENV_VAR: AGENT_BACKEND_CLAUDE_CODE, AUTOSKILLIT_APPLICABLE_GUARDS: ",".join(sorted(self.capabilities.applicable_guards)), @@ -613,20 +641,14 @@ def _build_skill_session_cmd_impl( extras["CLAUDE_CODE_EXIT_AFTER_STOP_DELAY"] = str(exit_after_stop_delay_ms) if stream_idle_timeout_ms > 0: extras["CLAUDE_STREAM_IDLE_TIMEOUT_MS"] = str(stream_idle_timeout_ms) - if scenario_step_name: - extras["SCENARIO_STEP_NAME"] = scenario_step_name - campaign_id = os.environ.get(CAMPAIGN_ID_ENV_VAR) - if campaign_id: - extras[CAMPAIGN_ID_ENV_VAR] = campaign_id - kitchen_session_id = os.environ.get(KITCHEN_SESSION_ID_ENV_VAR) - if kitchen_session_id: - extras[KITCHEN_SESSION_ID_ENV_VAR] = kitchen_session_id - if allowed_write_prefix: - extras["AUTOSKILLIT_ALLOWED_WRITE_PREFIX"] = allowed_write_prefix - if allowed_write_prefixes: - extras["AUTOSKILLIT_ALLOWED_WRITE_PREFIXES"] = ":".join(allowed_write_prefixes) - if cwd: - extras["AUTOSKILLIT_CWD"] = cwd + extras.update( + self._assemble_shared_env_extras( + scenario_step_name=scenario_step_name, + allowed_write_prefix=allowed_write_prefix, + allowed_write_prefixes=allowed_write_prefixes, + cwd=cwd, + ) + ) extras["AUTOSKILLIT_SKILL_NAME"] = extract_skill_name(skill_command) or "" if provider_extras: for k, v in provider_extras.items(): @@ -705,8 +727,6 @@ def build_food_truck_cmd( extras: dict[str, str] = { "AUTOSKILLIT_HEADLESS": "1", "AUTOSKILLIT_SESSION_TYPE": SESSION_TYPE_ORCHESTRATOR, - "MAX_MCP_OUTPUT_TOKENS": _MAX_MCP_OUTPUT_TOKENS_VALUE, - "MCP_CONNECTION_NONBLOCKING": "0", AGENT_BACKEND_ENV_VAR: AGENT_BACKEND_CLAUDE_CODE, AGENT_BACKEND_DYNACONF_ENV_VAR: AGENT_BACKEND_CLAUDE_CODE, AUTOSKILLIT_APPLICABLE_GUARDS: ",".join(sorted(self.capabilities.applicable_guards)), @@ -718,17 +738,14 @@ def build_food_truck_cmd( extras["CLAUDE_CODE_EXIT_AFTER_STOP_DELAY"] = str(exit_after_stop_delay_ms) if stream_idle_timeout_ms > 0: extras["CLAUDE_STREAM_IDLE_TIMEOUT_MS"] = str(stream_idle_timeout_ms) - if scenario_step_name: - extras["SCENARIO_STEP_NAME"] = scenario_step_name - kitchen_session_id = os.environ.get(KITCHEN_SESSION_ID_ENV_VAR) - if kitchen_session_id: - extras[KITCHEN_SESSION_ID_ENV_VAR] = kitchen_session_id - if allowed_write_prefix: - extras["AUTOSKILLIT_ALLOWED_WRITE_PREFIX"] = allowed_write_prefix - if allowed_write_prefixes: - extras["AUTOSKILLIT_ALLOWED_WRITE_PREFIXES"] = ":".join(allowed_write_prefixes) - if cwd: - extras["AUTOSKILLIT_CWD"] = cwd + extras.update( + self._assemble_shared_env_extras( + scenario_step_name=scenario_step_name, + allowed_write_prefix=allowed_write_prefix, + allowed_write_prefixes=allowed_write_prefixes, + cwd=cwd, + ) + ) if env_extras: for k, v in env_extras.items(): if k not in _PROVIDER_EXTRAS_BASE_DENYLIST: diff --git a/src/autoskillit/execution/backends/codex.py b/src/autoskillit/execution/backends/codex.py index e24060c16..979ef2839 100644 --- a/src/autoskillit/execution/backends/codex.py +++ b/src/autoskillit/execution/backends/codex.py @@ -21,13 +21,11 @@ AUTOSKILLIT_APPLICABLE_GUARDS, AUTOSKILLIT_PRIVATE_ENV_VARS, AUTOSKILLIT_WRITE_GUARD_TOOL_NAMES, - CAMPAIGN_ID_ENV_VAR, CODEX_EFFORT_MAPPING, CODEX_INTERACTIVE_REQUIRED_ENV, CODEX_MCP_ENV_FORWARD_VARS, CODEX_MODEL_ALIASES, FOOD_TRUCK_TOOL_TAGS_ENV_VAR, - KITCHEN_SESSION_ID_ENV_VAR, MCP_CLIENT_BACKEND_ENV_VAR, ORCHESTRATOR_SESSION_REQUIRED_ENV, RESUME_SESSION_BASELINE_KEYS, @@ -40,6 +38,7 @@ ClaudeDirectoryConventions, CmdSpec, CodexEventType, + EnvPolicy, NamedResume, NoResume, OutputFormat, @@ -56,9 +55,12 @@ load_yaml, pkg_root, ) +from autoskillit.execution.backends._backend_cmd_builder_base import ( + BackendCmdBuilderBase, + FlagVocabulary, +) from autoskillit.execution.backends._claude_prompt import ( _HEADLESS_EXCLUSIVE_VARS, - _MAX_MCP_OUTPUT_TOKENS_VALUE, _PROVIDER_EXTRAS_BASE_DENYLIST, _SESSION_BASELINE_ENV, _SKILL_SESSION_EXTRAS_DENYLIST, @@ -138,6 +140,16 @@ class CodexFlags(StrEnum): ) +CODEX_FLAG_VOCABULARY = FlagVocabulary( + variadic_flags=VARIADIC_CODEX_FLAGS, + non_variadic_flags=NON_VARIADIC_CODEX_FLAGS, + model_flag=CodexFlags.MODEL, + add_dir_flag=CodexFlags.ADD_DIR, + resume_flag=CodexFlags.RESUME_SUBCOMMAND, + config_override_flag=CodexFlags.CONFIG_OVERRIDE, +) + + CODEX_ENV_DENYLIST: frozenset[str] = frozenset( { "ANTHROPIC_API_KEY", @@ -471,7 +483,23 @@ def _generate_agent_tomls(session_dir: Path) -> int: @dataclass(frozen=True, slots=True) -class CodexBackend: +class CodexBackend(BackendCmdBuilderBase): + @property + def _binary(self) -> str: + return "codex" + + @property + def _sandbox_default(self) -> str: + return "workspace-write" + + @property + def _env_policy(self) -> EnvPolicy: + return CodexEnvPolicy() + + @property + def _flag_vocabulary(self) -> FlagVocabulary: + return CODEX_FLAG_VOCABULARY + @property def name(self) -> str: return AGENT_BACKEND_CODEX @@ -677,22 +705,14 @@ def build_skill_session_cmd( applicable_guards=self.capabilities.applicable_guards, write_guard_tool_names=self.capabilities.write_guard_tool_names, ) - extras["MAX_MCP_OUTPUT_TOKENS"] = _MAX_MCP_OUTPUT_TOKENS_VALUE - extras["MCP_CONNECTION_NONBLOCKING"] = "0" - if scenario_step_name: - extras["SCENARIO_STEP_NAME"] = scenario_step_name - campaign_id = os.environ.get(CAMPAIGN_ID_ENV_VAR) - if campaign_id: - extras[CAMPAIGN_ID_ENV_VAR] = campaign_id - kitchen_session_id = os.environ.get(KITCHEN_SESSION_ID_ENV_VAR) - if kitchen_session_id: - extras[KITCHEN_SESSION_ID_ENV_VAR] = kitchen_session_id - if allowed_write_prefix: - extras["AUTOSKILLIT_ALLOWED_WRITE_PREFIX"] = allowed_write_prefix - if allowed_write_prefixes: - extras["AUTOSKILLIT_ALLOWED_WRITE_PREFIXES"] = ":".join(allowed_write_prefixes) - if cwd: - extras["AUTOSKILLIT_CWD"] = cwd + extras.update( + self._assemble_shared_env_extras( + scenario_step_name=scenario_step_name, + allowed_write_prefix=allowed_write_prefix, + allowed_write_prefixes=allowed_write_prefixes, + cwd=cwd, + ) + ) extras["AUTOSKILLIT_SKILL_NAME"] = extract_skill_name(skill_command) or "" if provider_extras: for k, v in provider_extras.items(): @@ -784,24 +804,16 @@ def build_food_truck_cmd( applicable_guards=self.capabilities.applicable_guards, write_guard_tool_names=self.capabilities.write_guard_tool_names, ) - extras["MAX_MCP_OUTPUT_TOKENS"] = _MAX_MCP_OUTPUT_TOKENS_VALUE - extras["MCP_CONNECTION_NONBLOCKING"] = "0" - if scenario_step_name: - extras["SCENARIO_STEP_NAME"] = scenario_step_name - campaign_id = os.environ.get(CAMPAIGN_ID_ENV_VAR) - if campaign_id: - extras[CAMPAIGN_ID_ENV_VAR] = campaign_id - kitchen_session_id = os.environ.get(KITCHEN_SESSION_ID_ENV_VAR) - if kitchen_session_id: - extras[KITCHEN_SESSION_ID_ENV_VAR] = kitchen_session_id + extras.update( + self._assemble_shared_env_extras( + scenario_step_name=scenario_step_name, + allowed_write_prefix=allowed_write_prefix, + allowed_write_prefixes=allowed_write_prefixes, + cwd=cwd, + ) + ) if completion_marker: extras["AUTOSKILLIT_COMPLETION_MARKER"] = completion_marker - if allowed_write_prefix: - extras["AUTOSKILLIT_ALLOWED_WRITE_PREFIX"] = allowed_write_prefix - if allowed_write_prefixes: - extras["AUTOSKILLIT_ALLOWED_WRITE_PREFIXES"] = ":".join(allowed_write_prefixes) - if cwd: - extras["AUTOSKILLIT_CWD"] = cwd if env_extras: for k, v in env_extras.items(): if k not in _PROVIDER_EXTRAS_BASE_DENYLIST: diff --git a/tests/arch/test_subpackage_isolation.py b/tests/arch/test_subpackage_isolation.py index 02a74fc68..e1c38bb85 100644 --- a/tests/arch/test_subpackage_isolation.py +++ b/tests/arch/test_subpackage_isolation.py @@ -98,6 +98,8 @@ def _get_call_func_name(node: ast.Call) -> str | None: "methodology_venue_appendix", # recipe/methodology_venue_appendix.py: _ML_SUB_AREA_CACHE "rules_blocks", # recipe/rules/rules_blocks.py: _BUDGETS_CACHE = YamlFileCache() "rules_phoropter_adjacency", # recipe/rules/rules_phoropter_adjacency.py: _PREFIXES_CACHE + "claude", # execution/backends/claude.py: CLAUDE_FLAG_VOCABULARY = FlagVocabulary(...) + "codex", # execution/backends/codex.py: CODEX_FLAG_VOCABULARY = FlagVocabulary(...) } ) _SINGLETON_SAFE_CALL_NAMES: frozenset[str] = frozenset( @@ -979,7 +981,7 @@ def test_data_directories_are_not_python_packages() -> None: "dispatch gate add defense-in-depth checks", ), "execution/backends/codex.py": ( - 1060, + 1070, "REQ-CNST-010-E9: Codex backend — skill_sigil capability threading adds multi-line " "keyword args to _ensure_skill_prefix call sites and _has_prefix guard; " "write_guard_tool_names env injection adds 7 lines to _codex_exec_extras; " @@ -991,7 +993,10 @@ def test_data_directories_are_not_python_packages() -> None: "; project_log_dir method added to CodexSessionLocator (+3 net lines)" "; session_log_path method added to CodexSessionLocator (+5 net lines)" "; process_idle_timeout_ms field wired through build_skill_session_cmd and " - "build_food_truck_cmd CmdSpec constructors (+8 net lines)", + "build_food_truck_cmd CmdSpec constructors (+8 net lines)" + "; BackendCmdBuilderBase inheritance adds four abstract property implementations " + "(_binary, _sandbox_default, _env_policy, _flag_vocabulary) to CodexBackend " + "(+5 net lines)", ), } diff --git a/tests/execution/backends/test_backend_cmd_builder_base.py b/tests/execution/backends/test_backend_cmd_builder_base.py new file mode 100644 index 000000000..cd278a58e --- /dev/null +++ b/tests/execution/backends/test_backend_cmd_builder_base.py @@ -0,0 +1,196 @@ +"""Tests for ``_backend_cmd_builder_base``. + +Covers the ``BackendCmdBuilderBase`` ABC and ``FlagVocabulary`` NamedTuple. +""" + +from __future__ import annotations + +import pytest + +from autoskillit.core import ( + SkillSessionConfig, +) +from autoskillit.execution.backends._backend_cmd_builder_base import ( + BackendCmdBuilderBase, + FlagVocabulary, +) +from autoskillit.execution.backends.claude import ClaudeCodeBackend +from autoskillit.execution.backends.codex import CodexBackend + +pytestmark = [pytest.mark.layer("execution"), pytest.mark.small] + + +class TestFlagVocabulary: + def test_field_names(self) -> None: + assert FlagVocabulary._fields == ( + "variadic_flags", + "non_variadic_flags", + "model_flag", + "add_dir_flag", + "resume_flag", + "config_override_flag", + ) + + def test_field_types_via_instance(self) -> None: + fv = FlagVocabulary( + variadic_flags=frozenset({"--add-dir"}), + non_variadic_flags=frozenset({"--model"}), + model_flag="--model", + add_dir_flag="--add-dir", + resume_flag="--resume", + config_override_flag="", + ) + assert isinstance(fv.variadic_flags, frozenset) + assert isinstance(fv.non_variadic_flags, frozenset) + assert isinstance(fv.model_flag, str) + assert isinstance(fv.add_dir_flag, str) + assert isinstance(fv.resume_flag, str) + assert isinstance(fv.config_override_flag, str) + + +class TestAssembleSharedEnvExtras: + def test_all_eight_keys_present(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AUTOSKILLIT_CAMPAIGN_ID", "camp-1") + monkeypatch.setenv("AUTOSKILLIT_KITCHEN_SESSION_ID", "kitchen-1") + backend = ClaudeCodeBackend() + result = backend._assemble_shared_env_extras( + scenario_step_name="step-1", + allowed_write_prefix="/tmp/write", + allowed_write_prefixes=("/tmp/a", "/tmp/b"), + cwd="/workspace", + ) + assert result["MAX_MCP_OUTPUT_TOKENS"] == "50000" + assert result["MCP_CONNECTION_NONBLOCKING"] == "0" + assert result["AUTOSKILLIT_CAMPAIGN_ID"] == "camp-1" + assert result["AUTOSKILLIT_KITCHEN_SESSION_ID"] == "kitchen-1" + assert result["AUTOSKILLIT_ALLOWED_WRITE_PREFIX"] == "/tmp/write" + assert result["AUTOSKILLIT_ALLOWED_WRITE_PREFIXES"] == "/tmp/a:/tmp/b" + assert result["AUTOSKILLIT_CWD"] == "/workspace" + assert result["SCENARIO_STEP_NAME"] == "step-1" + + def test_conditional_keys_absent_when_falsy(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("AUTOSKILLIT_CAMPAIGN_ID", raising=False) + monkeypatch.delenv("AUTOSKILLIT_KITCHEN_SESSION_ID", raising=False) + backend = ClaudeCodeBackend() + result = backend._assemble_shared_env_extras() + assert "AUTOSKILLIT_CAMPAIGN_ID" not in result + assert "AUTOSKILLIT_KITCHEN_SESSION_ID" not in result + assert "AUTOSKILLIT_ALLOWED_WRITE_PREFIX" not in result + assert "AUTOSKILLIT_ALLOWED_WRITE_PREFIXES" not in result + assert "AUTOSKILLIT_CWD" not in result + assert "SCENARIO_STEP_NAME" not in result + # Unconditional keys always present + assert "MAX_MCP_OUTPUT_TOKENS" in result + assert "MCP_CONNECTION_NONBLOCKING" in result + + def test_session_type_is_not_assembled(self) -> None: + """AUTOSKILLIT_SESSION_TYPE is a backend-specific concern and must + not appear in the shared extras output.""" + backend = ClaudeCodeBackend() + result = backend._assemble_shared_env_extras( + scenario_step_name="step-1", + allowed_write_prefix="/p", + allowed_write_prefixes=("/a", "/b"), + cwd="/w", + ) + assert "AUTOSKILLIT_SESSION_TYPE" not in result + + def test_codex_backend_uses_same_assembly(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AUTOSKILLIT_CAMPAIGN_ID", "camp-2") + backend = CodexBackend() + result = backend._assemble_shared_env_extras( + scenario_step_name="step-2", + allowed_write_prefix="/p2", + allowed_write_prefixes=("/c", "/d"), + cwd="/w2", + ) + assert result["MAX_MCP_OUTPUT_TOKENS"] == "50000" + assert result["MCP_CONNECTION_NONBLOCKING"] == "0" + assert result["AUTOSKILLIT_CAMPAIGN_ID"] == "camp-2" + assert result["SCENARIO_STEP_NAME"] == "step-2" + assert result["AUTOSKILLIT_ALLOWED_WRITE_PREFIX"] == "/p2" + assert result["AUTOSKILLIT_ALLOWED_WRITE_PREFIXES"] == "/c:/d" + assert result["AUTOSKILLIT_CWD"] == "/w2" + + +class TestApplyConfig: + def test_round_trip(self) -> None: + config = SkillSessionConfig( + completion_marker="DONE", + model="sonnet", + scenario_step_name="step-x", + allowed_write_prefix="/tmp/w", + sandbox_mode="read-only", + ) + backend = ClaudeCodeBackend() + result = backend._apply_config(config) + assert result["completion_marker"] == "DONE" + assert result["model"] == "sonnet" + assert result["scenario_step_name"] == "step-x" + assert result["allowed_write_prefix"] == "/tmp/w" + assert result["sandbox_mode"] == "read-only" + + def test_all_eighteen_fields_present(self) -> None: + config = SkillSessionConfig() + backend = CodexBackend() + result = backend._apply_config(config) + expected_fields = { + "completion_marker", + "model", + "plugin_source", + "output_format", + "add_dirs", + "exit_after_stop_delay_ms", + "stream_idle_timeout_ms", + "scenario_step_name", + "temp_dir_relpath", + "allowed_write_prefix", + "allowed_write_prefixes", + "provider_extras", + "profile_name", + "resume_session_id", + "resume_checkpoint", + "resume_message", + "sandbox_mode", + "backend_override", + } + assert set(result.keys()) == expected_fields + assert len(result) == 18 + + +class TestInheritance: + def test_claude_is_subclass(self) -> None: + assert isinstance(ClaudeCodeBackend(), BackendCmdBuilderBase) + + def test_codex_is_subclass(self) -> None: + assert isinstance(CodexBackend(), BackendCmdBuilderBase) + + +class TestExtensionPoints: + def test_claude_binary(self) -> None: + assert ClaudeCodeBackend()._binary == "claude" + + def test_codex_binary(self) -> None: + assert CodexBackend()._binary == "codex" + + def test_claude_sandbox_default(self) -> None: + assert ClaudeCodeBackend()._sandbox_default == "" + + def test_codex_sandbox_default(self) -> None: + assert CodexBackend()._sandbox_default == "workspace-write" + + def test_claude_flag_vocabulary(self) -> None: + fv = ClaudeCodeBackend()._flag_vocabulary + assert "--model" in fv.non_variadic_flags + assert "--add-dir" in fv.variadic_flags + assert fv.model_flag == "--model" + assert fv.add_dir_flag == "--add-dir" + assert fv.resume_flag == "--resume" + assert fv.config_override_flag == "" + + def test_codex_flag_vocabulary(self) -> None: + fv = CodexBackend()._flag_vocabulary + assert "-c" in fv.variadic_flags + assert fv.config_override_flag == "-c" + assert fv.model_flag == "--model" + assert fv.add_dir_flag == "--add-dir"