diff --git a/apps/backend/agents/runtime/direct_api_autonomy.py b/apps/backend/agents/runtime/direct_api_autonomy.py index 57ea7dbde..01e9b81ed 100644 --- a/apps/backend/agents/runtime/direct_api_autonomy.py +++ b/apps/backend/agents/runtime/direct_api_autonomy.py @@ -10,6 +10,7 @@ from __future__ import annotations import json +import logging import os from collections.abc import Mapping from dataclasses import asdict, dataclass @@ -17,6 +18,7 @@ from pathlib import Path from typing import Any +from core.autonomy_level import AUTONOMY_LEVEL_ENV, resolve_autonomy_settings from core.autonomy_policy import ( DEFAULT_ALLOWED_PHASES, DEFAULT_MAX_HISTORY_AGE_DAYS, @@ -33,6 +35,8 @@ resolve_provider_smoke_history_path, ) +logger = logging.getLogger(__name__) + DIRECT_API_AUTONOMOUS_ENV = "AUTO_CODE_DIRECT_API_FULL_AUTONOMOUS" # Backward-compat aliases. Prefer ``AutonomyPolicy`` for runtime decisions. DIRECT_API_AUTONOMOUS_PROVIDERS = DIRECT_API_PROVIDERS @@ -112,13 +116,38 @@ def resolve_direct_api_autonomous_gate( missing_requirements=["coding_phase"], history_path=history_path_str, ) - if not direct_api_autonomous_env_enabled(env): + # Bridge the single AUTO_CODE_AUTONOMY knob (ADR-006) into the gate. + # ``resolve_autonomy_settings`` already folds the level default and the + # deprecated ``AUTO_CODE_DIRECT_API_FULL_AUTONOMOUS`` override into one + # flag, so safe/bold enable the gate without the legacy env var while + # that env var keeps working for back-compat. + autonomy = resolve_autonomy_settings(env=env) + if not autonomy.direct_api_gate_enabled: return DirectApiAutonomousGate( provider=provider, allowed=False, status="disabled", - reason="direct_api_autonomous_env_disabled", - missing_requirements=[DIRECT_API_AUTONOMOUS_ENV], + reason="direct_api_autonomous_disabled", + missing_requirements=[AUTONOMY_LEVEL_ENV], + history_path=history_path_str, + ) + if autonomy.direct_api_skip_gate: + # bold is the power-user level: it trusts the operator and grants + # promotion without provider e2e evidence. Surface that loudly so + # an unproven provider running full-autonomous is never silent. + logger.warning( + "Direct-API autonomous gate bypassed for %s via %s=%s: promotion " + "granted WITHOUT provider e2e evidence (power-user mode).", + provider, + AUTONOMY_LEVEL_ENV, + autonomy.level.value, + ) + return DirectApiAutonomousGate( + provider=provider, + allowed=True, + status="passed", + reason="direct_api_autonomous_gate_skipped", + missing_requirements=[], history_path=history_path_str, ) diff --git a/apps/backend/cli/runtime_commands.py b/apps/backend/cli/runtime_commands.py index 6b7853c08..824219509 100644 --- a/apps/backend/cli/runtime_commands.py +++ b/apps/backend/cli/runtime_commands.py @@ -2344,15 +2344,15 @@ def build_runtime_modes_payload( "provider as autonomous." ), "direct_api_autonomous_runtime": ( - f"Set {DIRECT_API_AUTONOMOUS_ENV}=true only after the provider " - "autonomous readiness and promotion gates are clean; the coder " - "phase can then use the direct_api_autonomous adapter. QA " - "phases (qa_reviewer / qa_fixer) still require Claude + " - "full_autonomous and will fail fast on any direct provider " - "until Phase 1.1 / 1.2 / 1.3 capability work (MCP execution, " - "mutating subagents, sandbox enforcement) is wired into the " - "QA runtime path. Prefer AUTO_CODE_AUTONOMY=safe (see " - "ADR-006); the legacy env var still works but is deprecated." + "Set AUTO_CODE_AUTONOMY=safe to let the coder phase use the " + "direct_api_autonomous adapter once the provider readiness and " + "promotion gates are clean (AUTO_CODE_AUTONOMY=bold skips the " + "evidence gate for power users). The legacy " + f"{DIRECT_API_AUTONOMOUS_ENV}=true still enables it but is " + "deprecated (see ADR-006). QA phases (qa_reviewer / qa_fixer) " + "still require Claude + full_autonomous and will fail fast on " + "any direct provider until the QA runtime path gains the MCP " + "execution, mutating subagents, and sandbox capabilities." ), "autonomy_level": ( "Set AUTO_CODE_AUTONOMY=off|claude|safe|bold as the single " diff --git a/tests/test_agent_runtime.py b/tests/test_agent_runtime.py index f9536c2ff..3b58ef8c9 100644 --- a/tests/test_agent_runtime.py +++ b/tests/test_agent_runtime.py @@ -742,6 +742,7 @@ def test_direct_api_autonomous_gate_requires_env_and_clean_history( ): _write_direct_api_autonomous_history(tmp_path) monkeypatch.delenv(DIRECT_API_AUTONOMOUS_ENV, raising=False) + monkeypatch.delenv("AUTO_CODE_AUTONOMY", raising=False) disabled_gate = resolve_direct_api_autonomous_gate( provider_name="openai", @@ -751,7 +752,7 @@ def test_direct_api_autonomous_gate_requires_env_and_clean_history( assert disabled_gate.allowed is False assert disabled_gate.status == "disabled" - assert disabled_gate.reason == "direct_api_autonomous_env_disabled" + assert disabled_gate.reason == "direct_api_autonomous_disabled" monkeypatch.setenv(DIRECT_API_AUTONOMOUS_ENV, "true") passed_gate = resolve_direct_api_autonomous_gate( @@ -767,6 +768,87 @@ def test_direct_api_autonomous_gate_requires_env_and_clean_history( assert passed_gate.missing_requirements == [] +def test_direct_api_autonomous_gate_enabled_via_autonomy_safe( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + """AUTO_CODE_AUTONOMY=safe enables the gate without the legacy env var.""" + _write_direct_api_autonomous_history(tmp_path) + monkeypatch.delenv(DIRECT_API_AUTONOMOUS_ENV, raising=False) + monkeypatch.setenv("AUTO_CODE_AUTONOMY", "safe") + + gate = resolve_direct_api_autonomous_gate( + provider_name="openai", + project_dir=tmp_path, + phase="coding", + ) + + assert gate.allowed is True + assert gate.status == "passed" + assert gate.reason == "direct_api_autonomous_gate_passed" + + +def test_direct_api_autonomous_gate_safe_still_requires_evidence( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + """safe enables the gate but evidence is still mandatory.""" + monkeypatch.delenv(DIRECT_API_AUTONOMOUS_ENV, raising=False) + monkeypatch.setenv("AUTO_CODE_AUTONOMY", "safe") + + gate = resolve_direct_api_autonomous_gate( + provider_name="openai", + project_dir=tmp_path, + phase="coding", + ) + + assert gate.allowed is False + assert gate.reason == "provider_history_missing" + assert gate.missing_requirements == ["provider_e2e_history"] + + +def test_direct_api_autonomous_gate_bold_skips_evidence( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + """bold is the power-user level: promotion is granted without evidence.""" + monkeypatch.delenv(DIRECT_API_AUTONOMOUS_ENV, raising=False) + monkeypatch.setenv("AUTO_CODE_AUTONOMY", "bold") + + # No history artifact exists at all, yet bold still grants. + gate = resolve_direct_api_autonomous_gate( + provider_name="openai", + project_dir=tmp_path, + phase="coding", + ) + + assert gate.allowed is True + assert gate.status == "passed" + assert gate.reason == "direct_api_autonomous_gate_skipped" + assert gate.missing_requirements == [] + + +def test_direct_api_autonomous_gate_disabled_on_claude_level( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + """claude/off never enable direct-API autonomy, regardless of evidence.""" + _write_direct_api_autonomous_history(tmp_path) + monkeypatch.delenv(DIRECT_API_AUTONOMOUS_ENV, raising=False) + monkeypatch.setenv("AUTO_CODE_AUTONOMY", "claude") + + gate = resolve_direct_api_autonomous_gate( + provider_name="openai", + project_dir=tmp_path, + phase="coding", + ) + + assert gate.allowed is False + assert gate.status == "disabled" + assert gate.reason == "direct_api_autonomous_disabled" + assert gate.missing_requirements == ["AUTO_CODE_AUTONOMY"] + + def test_direct_api_autonomous_gate_blocks_missing_corrupt_and_stale_history( tmp_path: Path, monkeypatch: pytest.MonkeyPatch,