diff --git a/src/skillspector/providers/_agent_cli.py b/src/skillspector/providers/_agent_cli.py index e69d71c2b..fe07a452c 100644 --- a/src/skillspector/providers/_agent_cli.py +++ b/src/skillspector/providers/_agent_cli.py @@ -461,6 +461,11 @@ def _parse_gemini_output(raw: str) -> str: _OPENCODE_AGENT_PREFIX = "skillspector-deny-all" _OPENCODE_DENY_ALL = json.dumps({"*": "deny"}, separators=(",", ":")) _OPENCODE_SUPPORTED_VERSION = "1.18.33" +# Markers of a Zen free-tier refusal. Zen answers 403 to +# any call carrying the deny-all isolation (OPENCODE_CONFIG_CONTENT or +# OPENCODE_PERMISSION alone suffices; verified by env bisect), so free-tier +# models and this sandbox are mutually exclusive until Zen's policy changes. +_ZEN_FREE_TIER_MARKERS = ("FreeTierError", "can only be used from within OpenCode") def _opencode_agent_name(argv: list[str]) -> str: @@ -1245,6 +1250,17 @@ def run_agent_cli( raise AgentCLIError(f"{binary_name} timed out after {timeout}s") if returncode != 0: stderr_snippet = stderr_raw[:500].decode("utf-8", errors="replace") + stdout_snippet = stdout_raw[:2000].decode("utf-8", errors="replace") + if binary_name == "opencode" and any( + marker in stdout_snippet for marker in _ZEN_FREE_TIER_MARKERS + ): + raise AgentCLIError( + "opencode Zen refused the call: its free tier can only be used " + "from within OpenCode, and it rejects the deny-all sandbox " + "isolation this provider requires. Use a key-backed model " + "instead, or retry if Zen's policy changes. Refusing to " + "weaken the sandbox: no fallback without isolation." + ) raise AgentCLIError( f"{binary_name} exited with code {returncode}; stderr={stderr_snippet!r}" ) diff --git a/tests/provider/test_opencode_cli.py b/tests/provider/test_opencode_cli.py index c253eaf03..a2459c2a7 100644 --- a/tests/provider/test_opencode_cli.py +++ b/tests/provider/test_opencode_cli.py @@ -32,11 +32,13 @@ from __future__ import annotations +import io import json import os import subprocess import sys import textwrap +from dataclasses import replace from pathlib import Path from types import SimpleNamespace from unittest.mock import MagicMock, patch @@ -60,6 +62,7 @@ _parse_opencode_output, _prepare_opencode_env, _run_bounded, + get_spec, run_agent_cli, ) from skillspector.providers.opencode_cli import OpencodeCLIProvider @@ -549,3 +552,63 @@ def test_provider_name_label(self) -> None: def test_has_cli_capability(self) -> None: assert has_cli_capability(OpencodeCLIProvider()) + + +# --------------------------------------------------------------------------- +# Zen free-tier refusal mapping +# --------------------------------------------------------------------------- + +# Verbatim envelope shape from a Zen 403 (free tier refused +# under the deny-all isolation; any permission deny trips it). +_ZEN_REFUSAL_ENVELOPE = ( + '{"type":"error","timestamp":1789784969134,"sessionID":"ses_probe",' + '"error":{"name":"APIError","data":{"message":"Error from provider ' + "(Console): OpenCode's free tier can only be used from within OpenCode\"," + '"statusCode":403,"responseBody":"{\\"type\\":\\"error\\",\\"error\\":' + '{\\"type\\":\\"FreeTierError\\"}"}}}}}' +) + + +class _FakePopen: + """Minimal Popen stand-in for _run_bounded (cross-platform, no subprocess).""" + + def __init__(self, stdout: bytes, returncode: int) -> None: + self.stdin: io.BytesIO | None = io.BytesIO() + self.stdout: io.BytesIO | None = io.BytesIO(stdout) + self.stderr: io.BytesIO | None = io.BytesIO(b"") + self._returncode = returncode + + def wait(self, timeout: float | None = None) -> int: + return self._returncode + + def kill(self) -> None: + pass + + +class TestZenFreeTierRefusal: + def _run_with_fake_host( + self, monkeypatch: pytest.MonkeyPatch, stdout: bytes, returncode: int + ) -> str: + spec = replace(get_spec("opencode"), preflight=None) + monkeypatch.setattr(_agent_cli, "get_spec", lambda _name: spec) + monkeypatch.setattr(_agent_cli, "find_binary", lambda _name: "/usr/bin/opencode") + monkeypatch.setattr( + subprocess, + "Popen", + lambda *args, **kwargs: _FakePopen(stdout, returncode), + ) + return run_agent_cli("opencode", "probe", model="opencode/nemotron-3-ultra-free") + + def test_free_tier_refusal_raises_actionable_error( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A Zen FreeTierError envelope must fail closed with guidance — never + the opaque exit-code message, and never a silent sandbox weakening.""" + with pytest.raises(AgentCLIError, match="free tier can only be used"): + self._run_with_fake_host(monkeypatch, _ZEN_REFUSAL_ENVELOPE.encode(), 1) + + def test_plain_nonzero_exit_keeps_generic_message( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + with pytest.raises(AgentCLIError, match="exited with code 1"): + self._run_with_fake_host(monkeypatch, b"boom", 1)