314 lines
12 KiB
Python
314 lines
12 KiB
Python
"""Tests for the native OpenCode bridge state helpers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from omnigent import opencode_native_bridge as bridge
|
|
from omnigent.opencode_native_bridge import (
|
|
OpenCodeNativeBridgeState,
|
|
auth_headers_for_secret,
|
|
bridge_dir_for_bridge_id,
|
|
build_opencode_native_spawn_env,
|
|
clear_bridge_state,
|
|
ensure_auth_secret,
|
|
prepare_bridge_dir,
|
|
read_bridge_state,
|
|
update_active_message_id,
|
|
update_last_event_id,
|
|
update_model_override,
|
|
user_opencode_config_path,
|
|
write_bridge_state,
|
|
write_cost_popup_config,
|
|
write_opencode_policy_plugin,
|
|
write_relay_bridge_config,
|
|
xdg_config_home_for_bridge_dir,
|
|
xdg_data_home_for_bridge_dir,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def bridge_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
"""Isolated bridge directory rooted under a tmp path."""
|
|
monkeypatch.setattr(bridge, "_BRIDGE_ROOT", tmp_path / "opencode-native")
|
|
return prepare_bridge_dir("bridge_test")
|
|
|
|
|
|
def _state(bridge_dir: Path, **overrides: object) -> OpenCodeNativeBridgeState:
|
|
base = {
|
|
"session_id": "conv_abc",
|
|
"server_base_url": "http://127.0.0.1:49231",
|
|
"opencode_session_id": "ses_abc",
|
|
"auth_secret": "s3cret",
|
|
"xdg_data_home": str(xdg_data_home_for_bridge_dir(bridge_dir)),
|
|
"xdg_config_home": str(xdg_config_home_for_bridge_dir(bridge_dir)),
|
|
}
|
|
base.update(overrides)
|
|
return OpenCodeNativeBridgeState(**base) # type: ignore[arg-type]
|
|
|
|
|
|
def test_prepare_bridge_dir_creates_xdg_roots(bridge_dir: Path) -> None:
|
|
assert bridge_dir.is_dir()
|
|
assert xdg_data_home_for_bridge_dir(bridge_dir).is_dir()
|
|
assert xdg_config_home_for_bridge_dir(bridge_dir).is_dir()
|
|
# 0700 perms on the bridge dir.
|
|
assert (os.stat(bridge_dir).st_mode & 0o777) == 0o700
|
|
|
|
|
|
def test_write_read_state_round_trips(bridge_dir: Path) -> None:
|
|
write_bridge_state(bridge_dir, _state(bridge_dir, model_override="anthropic/claude-opus-4"))
|
|
loaded = read_bridge_state(bridge_dir)
|
|
assert loaded is not None
|
|
assert loaded.session_id == "conv_abc"
|
|
assert loaded.opencode_session_id == "ses_abc"
|
|
assert loaded.server_base_url == "http://127.0.0.1:49231"
|
|
assert loaded.auth_secret == "s3cret"
|
|
assert loaded.model_override == "anthropic/claude-opus-4"
|
|
assert loaded.status == "idle"
|
|
|
|
|
|
def test_read_missing_state_is_none(bridge_dir: Path) -> None:
|
|
assert read_bridge_state(bridge_dir) is None
|
|
|
|
|
|
def test_read_corrupt_state_is_none(bridge_dir: Path) -> None:
|
|
(bridge_dir / "state.json").write_text("{not json", encoding="utf-8")
|
|
assert read_bridge_state(bridge_dir) is None
|
|
|
|
|
|
def test_read_incomplete_state_is_none(bridge_dir: Path) -> None:
|
|
(bridge_dir / "state.json").write_text(json.dumps({"session_id": "x"}), encoding="utf-8")
|
|
assert read_bridge_state(bridge_dir) is None
|
|
|
|
|
|
def test_clear_state_removes_file(bridge_dir: Path) -> None:
|
|
write_bridge_state(bridge_dir, _state(bridge_dir))
|
|
clear_bridge_state(bridge_dir)
|
|
assert read_bridge_state(bridge_dir) is None
|
|
# Idempotent.
|
|
clear_bridge_state(bridge_dir)
|
|
|
|
|
|
def test_update_active_message_id(bridge_dir: Path) -> None:
|
|
write_bridge_state(bridge_dir, _state(bridge_dir))
|
|
update_active_message_id(bridge_dir, "msg_1", status="busy")
|
|
loaded = read_bridge_state(bridge_dir)
|
|
assert loaded is not None
|
|
assert loaded.active_message_id == "msg_1"
|
|
assert loaded.status == "busy"
|
|
update_active_message_id(bridge_dir, None, status="idle")
|
|
loaded = read_bridge_state(bridge_dir)
|
|
assert loaded is not None
|
|
assert loaded.active_message_id is None
|
|
assert loaded.status == "idle"
|
|
|
|
|
|
def test_update_model_override(bridge_dir: Path) -> None:
|
|
write_bridge_state(bridge_dir, _state(bridge_dir))
|
|
assert update_model_override(bridge_dir, "anthropic/claude-opus-4") is True
|
|
loaded = read_bridge_state(bridge_dir)
|
|
assert loaded is not None
|
|
assert loaded.model_override == "anthropic/claude-opus-4"
|
|
# Blank clears the override.
|
|
assert update_model_override(bridge_dir, " ") is True
|
|
loaded = read_bridge_state(bridge_dir)
|
|
assert loaded is not None
|
|
assert loaded.model_override is None
|
|
|
|
|
|
def test_update_model_override_no_state_returns_false(bridge_dir: Path) -> None:
|
|
# No bridge state written yet (server not launched).
|
|
assert update_model_override(bridge_dir, "x/y") is False
|
|
|
|
|
|
def test_write_relay_bridge_config_writes_token_and_is_idempotent(bridge_dir: Path) -> None:
|
|
write_relay_bridge_config(bridge_dir)
|
|
config_path = bridge_dir / "bridge.json"
|
|
assert config_path.exists()
|
|
payload = json.loads(config_path.read_text())
|
|
token = payload["token"]
|
|
assert isinstance(token, str) and token
|
|
# Idempotent: a second call must NOT rotate the token (the relay HTTP server
|
|
# may already have been started with it).
|
|
write_relay_bridge_config(bridge_dir)
|
|
assert json.loads(config_path.read_text())["token"] == token
|
|
|
|
|
|
def test_write_cost_popup_config_writes_ap_routing(bridge_dir: Path) -> None:
|
|
path = write_cost_popup_config(
|
|
bridge_dir,
|
|
ap_server_url="http://127.0.0.1:6767",
|
|
ap_auth_headers={"Authorization": "Bearer tok"},
|
|
)
|
|
payload = json.loads(path.read_text())
|
|
assert payload == {
|
|
"ap_server_url": "http://127.0.0.1:6767",
|
|
"ap_auth_headers": {"Authorization": "Bearer tok"},
|
|
}
|
|
# Rewritten (not skipped) so a later checkpoint gets a fresh token.
|
|
write_cost_popup_config(bridge_dir, ap_server_url="http://h:1", ap_auth_headers={})
|
|
assert json.loads(path.read_text()) == {"ap_server_url": "http://h:1", "ap_auth_headers": {}}
|
|
|
|
|
|
def test_write_opencode_policy_plugin(bridge_dir: Path) -> None:
|
|
path = write_opencode_policy_plugin(bridge_dir)
|
|
assert path.name == "omnigent-policy.js"
|
|
src = path.read_text(encoding="utf-8")
|
|
# The two phase hooks the reactive permission path can't reach.
|
|
assert '"chat.message"' in src # REQUEST phase
|
|
assert '"tool.execute.after"' in src # TOOL_RESULT phase
|
|
# Posts the proto phases + reads its coordinates from env.
|
|
assert "PHASE_REQUEST" in src and "PHASE_TOOL_RESULT" in src
|
|
assert "OMNIGENT_POLICY_URL" in src and "OMNIGENT_SESSION_ID" in src
|
|
assert "/policies/evaluate" in src
|
|
# A function export so opencode's Object.values(mod) loader picks it up.
|
|
assert "export const OmnigentPolicyPlugin" in src
|
|
# Idempotent overwrite (re-launch ships fresh code, no error).
|
|
assert write_opencode_policy_plugin(bridge_dir) == path
|
|
|
|
|
|
def test_update_last_event_id(bridge_dir: Path) -> None:
|
|
write_bridge_state(bridge_dir, _state(bridge_dir))
|
|
update_last_event_id(bridge_dir, "evt_42")
|
|
loaded = read_bridge_state(bridge_dir)
|
|
assert loaded is not None
|
|
assert loaded.last_event_id == "evt_42"
|
|
|
|
|
|
def test_ensure_auth_secret_is_stable_and_0600(bridge_dir: Path) -> None:
|
|
secret = ensure_auth_secret(bridge_dir)
|
|
assert secret
|
|
# Same secret on a second call (reused across server restarts).
|
|
assert ensure_auth_secret(bridge_dir) == secret
|
|
path = bridge_dir / "auth.secret"
|
|
assert (os.stat(path).st_mode & 0o777) == 0o600
|
|
|
|
|
|
def test_auth_headers_for_secret() -> None:
|
|
assert auth_headers_for_secret(None) == {}
|
|
headers = auth_headers_for_secret("pw")
|
|
assert headers["Authorization"].startswith("Basic ")
|
|
import base64
|
|
|
|
decoded = base64.b64decode(headers["Authorization"].split(" ", 1)[1]).decode()
|
|
assert decoded == "opencode:pw"
|
|
|
|
|
|
def test_state_auth_headers_method(bridge_dir: Path) -> None:
|
|
state = _state(bridge_dir, auth_secret="pw")
|
|
assert state.auth_headers()["Authorization"].startswith("Basic ")
|
|
|
|
|
|
def test_spawn_env_points_at_bridge_dir(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
monkeypatch.setattr(bridge, "_BRIDGE_ROOT", tmp_path / "opencode-native")
|
|
env = build_opencode_native_spawn_env("conv_abc")
|
|
assert env["HARNESS_OPENCODE_NATIVE_BRIDGE_DIR"] == str(bridge_dir_for_bridge_id("conv_abc"))
|
|
assert env["HARNESS_OPENCODE_NATIVE_REQUEST_SESSION_ID"] == "conv_abc"
|
|
|
|
|
|
def test_spawn_env_bridge_id_override(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
monkeypatch.setattr(bridge, "_BRIDGE_ROOT", tmp_path / "opencode-native")
|
|
env = build_opencode_native_spawn_env("conv_abc", bridge_id="bridge_xyz")
|
|
assert env["HARNESS_OPENCODE_NATIVE_BRIDGE_DIR"] == str(bridge_dir_for_bridge_id("bridge_xyz"))
|
|
assert env["HARNESS_OPENCODE_NATIVE_REQUEST_SESSION_ID"] == "conv_abc"
|
|
|
|
|
|
def test_seed_opencode_auth_copies_user_auth(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""The user's auth.json is copied into the per-session XDG_DATA_HOME (0600)."""
|
|
user_data = tmp_path / "user-share"
|
|
(user_data / "opencode").mkdir(parents=True)
|
|
(user_data / "opencode" / "auth.json").write_text('{"anthropic": {"type": "api"}}')
|
|
monkeypatch.setenv("XDG_DATA_HOME", str(user_data))
|
|
|
|
bridge_dir = bridge.prepare_bridge_dir("conv_seed")
|
|
dest = bridge.seed_opencode_auth(bridge_dir)
|
|
assert dest is not None and dest.is_file()
|
|
assert dest == bridge.xdg_data_home_for_bridge_dir(bridge_dir) / "opencode" / "auth.json"
|
|
assert "anthropic" in dest.read_text()
|
|
assert (os.stat(dest).st_mode & 0o777) == 0o600
|
|
|
|
|
|
def test_seed_opencode_auth_noop_without_source(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""No user auth.json → no-op (returns None), e.g. on a remote runner."""
|
|
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "empty-share"))
|
|
bridge_dir = bridge.prepare_bridge_dir("conv_noseed")
|
|
assert bridge.seed_opencode_auth(bridge_dir) is None
|
|
|
|
|
|
def test_user_opencode_config_path_default_location(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""Without XDG_CONFIG_HOME, looks at ~/.config/opencode/opencode.jsonc."""
|
|
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
|
|
fake_home = tmp_path / "fake_home"
|
|
monkeypatch.setattr(Path, "home", lambda: fake_home)
|
|
# File does not exist → returns None.
|
|
assert user_opencode_config_path() is None
|
|
# Create the file and verify the path is as expected.
|
|
(fake_home / ".config" / "opencode").mkdir(parents=True)
|
|
(fake_home / ".config" / "opencode" / "opencode.jsonc").write_text("{}")
|
|
expected = fake_home / ".config" / "opencode" / "opencode.jsonc"
|
|
assert user_opencode_config_path() == expected
|
|
|
|
|
|
def test_user_opencode_config_path_honors_xdg_config_home(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""XDG_CONFIG_HOME env var redirects the lookup."""
|
|
cfg_dir = tmp_path / "my-config" / "opencode"
|
|
cfg_dir.mkdir(parents=True)
|
|
(cfg_dir / "opencode.jsonc").write_text("{}", encoding="utf-8")
|
|
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "my-config"))
|
|
path = user_opencode_config_path()
|
|
assert path is not None and path.exists()
|
|
assert path.name == "opencode.jsonc"
|
|
|
|
|
|
def test_user_opencode_config_path_falls_back_to_json(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""When only opencode.json exists (no .jsonc), returns the .json path."""
|
|
cfg_dir = tmp_path / "cfg" / "opencode"
|
|
cfg_dir.mkdir(parents=True)
|
|
(cfg_dir / "opencode.json").write_text("{}", encoding="utf-8")
|
|
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg"))
|
|
path = user_opencode_config_path()
|
|
assert path is not None and path.name == "opencode.json"
|
|
|
|
|
|
def test_user_opencode_config_path_prefers_jsonc(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""When both .jsonc and .json exist, .jsonc is preferred."""
|
|
cfg_dir = tmp_path / "pref" / "opencode"
|
|
cfg_dir.mkdir(parents=True)
|
|
(cfg_dir / "opencode.jsonc").write_text("{}", encoding="utf-8")
|
|
(cfg_dir / "opencode.json").write_text("{}", encoding="utf-8")
|
|
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "pref"))
|
|
path = user_opencode_config_path()
|
|
assert path is not None and path.name == "opencode.jsonc"
|
|
|
|
|
|
def test_policy_plugin_merges_routing_headers(bridge_dir: Path) -> None:
|
|
"""The generated policy plugin merges OMNIGENT_POLICY_HEADERS into its
|
|
/policies/evaluate POST.
|
|
|
|
The runner bakes the full routing header map (bearer + workspace / deployment
|
|
selectors) into that env var, so the out-of-process plugin's callbacks reach
|
|
the same server instance as the runner instead of a different one.
|
|
"""
|
|
src = write_opencode_policy_plugin(bridge_dir).read_text(encoding="utf-8")
|
|
assert "OMNIGENT_POLICY_HEADERS" in src
|
|
# The routing map is spread into the request headers.
|
|
assert "...POLICY_HEADERS" in src
|
|
# The old bearer-only env var is fully removed.
|
|
assert "OMNIGENT_POLICY_AUTH" not in src
|