Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/skillspector/providers/_agent_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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}"
)
Expand Down
63 changes: 63 additions & 0 deletions tests/provider/test_opencode_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -60,6 +62,7 @@
_parse_opencode_output,
_prepare_opencode_env,
_run_bounded,
get_spec,
run_agent_cli,
)
from skillspector.providers.opencode_cli import OpencodeCLIProvider
Expand Down Expand Up @@ -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)
Loading