diff --git a/.agents/skills/lib_py/agents/__main__.py b/.agents/skills/lib_py/agents/__main__.py index 760eb35..e9162f9 100644 --- a/.agents/skills/lib_py/agents/__main__.py +++ b/.agents/skills/lib_py/agents/__main__.py @@ -117,6 +117,15 @@ def main(): print(adapter.resume_spec(binary, uuid, materialized=mat)) sys.exit(0) + elif cmd == 'auth-ok': + # auth-ok : exit 0 when the adapter's auth_ok() holds, else 1. + agent_name = sys.argv[2] if len(sys.argv) > 2 else '' + adapter = get_adapter(agent_name) + if not adapter: + print(f"ERROR: unknown agent {agent_name!r}", file=sys.stderr) + sys.exit(1) + sys.exit(0 if adapter.auth_ok() else 1) + elif cmd == 'exit-key': agent_name = sys.argv[2] if len(sys.argv) > 2 else '' adapter = get_adapter(agent_name) diff --git a/.agents/skills/lib_py/agents/adapters/opencode.py b/.agents/skills/lib_py/agents/adapters/opencode.py index 628e7ee..bb8ff9b 100644 --- a/.agents/skills/lib_py/agents/adapters/opencode.py +++ b/.agents/skills/lib_py/agents/adapters/opencode.py @@ -39,13 +39,6 @@ def _ro_connect(db: str): # (binary, HOME, XDG_DATA_HOME) -> the db `debug paths` printed for them. _DB_PATHS = {} -_PROVIDER_ENV_VARS = ( - 'ANTHROPIC_API_KEY', - 'OPENAI_API_KEY', - 'GEMINI_API_KEY', - 'GROQ_API_KEY' -) - class OpenCodeAgentAdapter(BaseAgentAdapter): @property def name(self) -> str: @@ -322,11 +315,10 @@ class OpenCodeAgentAdapter(BaseAgentAdapter): return f"{binary} --auto --standalone" def auth_ok(self, run_cmd: Optional[Any] = None) -> bool: - if any(os.environ.get(v) for v in _PROVIDER_ENV_VARS): - return True + # opencode 2.x needs no login: a fresh HOME runs a free default model + # and local providers have no key, so the binary is the whole check. home = os.environ.get("HOME_DIR") or os.environ.get("HOME") or os.path.expanduser("~") - auth_file = f"{home}/.local/share/opencode/auth.json" - return os.path.exists(auth_file) + return bool(shutil.which("opencode")) or os.path.exists(f"{home}/.opencode/bin/opencode") def discover(self, ctx: DiscoveryContext) -> list: db = self._resolve_db(ctx) diff --git a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh index aa3525f..7e9c131 100755 --- a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh +++ b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh @@ -143,9 +143,9 @@ elif [ "$AGENT" = "grok" ]; then exit 1 fi elif [ "$AGENT" = "opencode" ]; then - if [ -f "$HOME/.local/share/opencode/auth.json" ] || [ -n "${ANTHROPIC_API_KEY:-}" ] || [ -n "${OPENAI_API_KEY:-}" ] || [ -n "${GEMINI_API_KEY:-}" ] || [ -n "${GROQ_API_KEY:-}" ]; then - true - elif ! opencode --version >/dev/null 2>&1; then + # opencode needs no login (2.x: a free default model, keyless local + # providers); the adapter's auth_ok is the one verdict. + if ! "$(_delegate_py_bin)" -m lib_py.agents auth-ok opencode >/dev/null 2>&1; then echo "ERROR: opencode is not functional or configured." >&2 exit 1 fi diff --git a/tests/test_a4_adapter_contract.py b/tests/test_a4_adapter_contract.py index 2affd53..da66364 100644 --- a/tests/test_a4_adapter_contract.py +++ b/tests/test_a4_adapter_contract.py @@ -311,16 +311,24 @@ def test_adapter_auth_ok(tmp_path, monkeypatch): # Hermes always True assert get_adapter('hermes').auth_ok() is True - # OpenCode auth check (provider env var or auth.json file) + # OpenCode auth check: the binary alone (2.x needs no login, Issue #60); + # keys and auth.json do not count opencode = get_adapter('opencode') + path = os.environ["PATH"] + monkeypatch.setenv("PATH", str(tmp_path / "empty-bin")) assert opencode.auth_ok() is False monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key") - assert opencode.auth_ok() is True - monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) opencode_auth = tmp_path / ".local" / "share" / "opencode" / "auth.json" opencode_auth.parent.mkdir(parents=True, exist_ok=True) opencode_auth.write_text("{}") + assert opencode.auth_ok() is False + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + opencode_bin = tmp_path / ".opencode" / "bin" / "opencode" + opencode_bin.parent.mkdir(parents=True) + opencode_bin.write_text("#!/bin/sh\n") + opencode_bin.chmod(0o755) assert opencode.auth_ok() is True + monkeypatch.setenv("PATH", path) # Codex auth check (env var, auth.json file, or CLI) codex = get_adapter('codex') diff --git a/tests/test_issue60_linux_fixes.py b/tests/test_issue60_linux_fixes.py index e03ffa3..0b47d9b 100644 --- a/tests/test_issue60_linux_fixes.py +++ b/tests/test_issue60_linux_fixes.py @@ -9,6 +9,8 @@ (binary, HOME, XDG_DATA_HOME) in a process. 2. A graceful stop whose `/exit` worked says so: the agent exits instead of turning `working`, so the delivery is unconfirmed, and no kill step ran. +3. opencode's auth check is the binary alone (2.x needs no login), and create + takes the adapter's verdict instead of running `opencode --version`. """ from __future__ import annotations @@ -279,3 +281,75 @@ def test_the_graceful_stop_reports_what_happened(mam_sandbox, mock_herdr, mock_a else: # The kill step runs, so it is named. assert "still alive → kill-session" in out, out + + +# --------------------------------------------------------------------------- +# 3. opencode's auth check is the binary +# --------------------------------------------------------------------------- +def _executable(path: Path) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("#!/bin/sh\n") + path.chmod(0o755) + return path + + +@pytest.fixture +def bare_home(tmp_path, monkeypatch): + """An empty HOME_DIR and a PATH with no opencode.""" + monkeypatch.setenv("HOME_DIR", str(tmp_path / "home")) + monkeypatch.setenv("PATH", str(tmp_path / "empty-bin")) + for var in ("ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "GROQ_API_KEY"): + monkeypatch.delenv(var, raising=False) + return tmp_path + + +def test_keys_and_auth_json_without_the_binary_are_not_enough(bare_home, monkeypatch): + opencode = get_adapter("opencode") + assert opencode.auth_ok() is False + monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key") + assert opencode.auth_ok() is False + auth = bare_home / "home" / ".local" / "share" / "opencode" / "auth.json" + auth.parent.mkdir(parents=True) + auth.write_text("{}") + assert opencode.auth_ok() is False + + +@pytest.mark.parametrize("where", ["path", "home"]) +def test_the_binary_alone_is_enough(bare_home, monkeypatch, where): + if where == "path": + _executable(bare_home / "bin" / "opencode") + monkeypatch.setenv("PATH", str(bare_home / "bin")) + else: + _executable(bare_home / "home" / ".opencode" / "bin" / "opencode") + assert get_adapter("opencode").auth_ok() is True + + +def test_the_auth_ok_command_exits_with_the_verdict(bare_home): + def rc(*args): + env = dict(os.environ, PYTHONPATH=str(SKILLS)) + return subprocess.run([sys.executable, "-m", "lib_py.agents", "auth-ok", *args], + capture_output=True, text=True, env=env).returncode + + assert rc("opencode") == 1 + _executable(bare_home / "home" / ".opencode" / "bin" / "opencode") + assert rc("opencode") == 0 + assert rc("no-such-agent") == 1 + + +def test_create_decides_opencode_auth_without_running_it(mam_sandbox, mock_herdr, mock_agents, monkeypatch): + # Every opencode call create makes is logged; --version was the old check. + log = mam_sandbox / "opencode-calls.log" + wrap = mam_sandbox / "logbin" / "opencode" + wrap.parent.mkdir() + wrap.write_text(f'#!/bin/sh\necho "$*" >> "{log}"\nexec "{shutil.which("opencode")}" "$@"\n') + wrap.chmod(0o755) + monkeypatch.setenv("PATH", f"{wrap.parent}:{os.environ['PATH']}") + for var in ("ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "GROQ_API_KEY"): + monkeypatch.delenv(var, raising=False) + create = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh" + res = subprocess.run(["bash", str(create), "--workspace", str(mam_sandbox), "--agent", "opencode", + "--role", "creator", "--no-onboard"], + capture_output=True, text=True, cwd=str(mam_sandbox), timeout=300) + assert res.returncode == 0, res.stdout + res.stderr + calls = log.read_text().splitlines() if log.exists() else [] + assert "--version" not in calls, calls