324 lines
10 KiB
Python
324 lines
10 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from codex_support import (
|
|
AGENT_ID,
|
|
VALID_PRIVATE_KEY,
|
|
assert_native_handler,
|
|
legacy_handler,
|
|
managed_handler,
|
|
prepare_fixture,
|
|
symlink_or_skip,
|
|
write_json,
|
|
write_text,
|
|
)
|
|
from elydora.plugins.codex_contract import (
|
|
AUDIT_STATUS,
|
|
GUARD_STATUS,
|
|
build_handler,
|
|
)
|
|
from elydora.plugins.registry import SUPPORTED_AGENTS
|
|
|
|
|
|
def test_codex_registry_points_at_native_user_hooks() -> None:
|
|
assert SUPPORTED_AGENTS["codex"] == {
|
|
"name": "OpenAI Codex",
|
|
"hook_event": "PreToolUse/PostToolUse",
|
|
"config_path": "~/.codex/hooks.json",
|
|
}
|
|
|
|
|
|
def test_codex_follows_and_canonicalizes_codex_home(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
|
target = tmp_path / "custom Codex home"
|
|
configured = tmp_path / "codex-home-link"
|
|
target.mkdir()
|
|
try:
|
|
configured.symlink_to(target, target_is_directory=True)
|
|
except OSError:
|
|
configured = target
|
|
monkeypatch.setenv("CODEX_HOME", str(configured))
|
|
|
|
fixture.install()
|
|
|
|
expected = Path(os.path.realpath(target)) / "hooks.json"
|
|
assert expected.is_file()
|
|
assert fixture.config_path.exists() is False
|
|
status = fixture.plugin.status()
|
|
assert status["installed"] is True
|
|
assert status["details"] == f"Config: {expected}"
|
|
|
|
|
|
@pytest.mark.parametrize("kind", ["missing", "file"])
|
|
def test_codex_rejects_invalid_codex_home_before_writes(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
kind: str,
|
|
) -> None:
|
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
|
configured = tmp_path / f"{kind}-codex-home"
|
|
if kind == "file":
|
|
write_text(configured, "not a directory")
|
|
|
|
monkeypatch.setenv("CODEX_HOME", str(configured))
|
|
with pytest.raises(OSError, match="CODEX_HOME"):
|
|
fixture.install()
|
|
|
|
assert list(fixture.agent_dir.iterdir()) == []
|
|
assert fixture.config_path.exists() is False
|
|
|
|
|
|
def test_install_preserves_sources_migrates_legacy_and_is_idempotent(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
|
write_json(
|
|
fixture.config_path,
|
|
{
|
|
"description": "User hooks",
|
|
"metadata": {"owner": "user"},
|
|
"hooks": {
|
|
"SessionStart": [
|
|
{"matcher": "*", "hooks": [{"type": "command", "command": "keep"}]}
|
|
],
|
|
"PreToolUse": [
|
|
{
|
|
"matcher": "*",
|
|
"hooks": [legacy_handler(fixture.guard_path, GUARD_STATUS)],
|
|
}
|
|
],
|
|
"PostToolUse": [
|
|
{
|
|
"matcher": "*",
|
|
"hooks": [legacy_handler(fixture.hook_path, AUDIT_STATUS)],
|
|
}
|
|
],
|
|
},
|
|
},
|
|
)
|
|
|
|
fixture.install()
|
|
first = fixture.config_path.read_text(encoding="utf-8")
|
|
fixture.install()
|
|
second = fixture.config_path.read_text(encoding="utf-8")
|
|
|
|
assert first == second
|
|
assert "approve both Elydora command hooks" in capsys.readouterr().out
|
|
settings = fixture.settings()
|
|
assert settings["description"] == "User hooks"
|
|
assert settings["metadata"] == {"owner": "user"}
|
|
assert settings["hooks"]["SessionStart"][0]["hooks"][0]["command"] == "keep"
|
|
assert len(settings["hooks"]["PreToolUse"]) == 1
|
|
assert len(settings["hooks"]["PostToolUse"]) == 1
|
|
assert_native_handler(
|
|
managed_handler(settings, "PreToolUse", GUARD_STATUS),
|
|
GUARD_STATUS,
|
|
)
|
|
assert_native_handler(
|
|
managed_handler(settings, "PostToolUse", AUDIT_STATUS),
|
|
AUDIT_STATUS,
|
|
)
|
|
runtime = json.loads(fixture.runtime_config_path.read_text(encoding="utf-8"))
|
|
assert runtime["agent_name"] == "codex"
|
|
assert runtime["token"] == "token-1"
|
|
assert fixture.private_key_path.read_text(encoding="utf-8") == VALID_PRIVATE_KEY
|
|
assert fixture.guard_path.is_file()
|
|
assert fixture.hook_path.is_file()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("source", "error_pattern"),
|
|
[
|
|
('{"hooks":{},"hooks":{}}', "duplicate field"),
|
|
('{"hooks":{},}', "parse Codex user hooks"),
|
|
('{"hooks":{/*comment*/}}', "parse Codex user hooks"),
|
|
("[]", "must contain a JSON object"),
|
|
('{"hooks":null}', 'field "hooks" must be an object'),
|
|
('{"hooks":{"PreToolUse":null}}', 'hooks.PreToolUse" must be an array'),
|
|
(
|
|
'{"hooks":{"PreToolUse":[{"hooks":null}]}}',
|
|
"must contain a hooks array",
|
|
),
|
|
(
|
|
'{"hooks":{"PreToolUse":[{"hooks":[null]}]}}',
|
|
"must contain a hooks array",
|
|
),
|
|
],
|
|
)
|
|
def test_install_preserves_malformed_and_ambiguous_json(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
source: str,
|
|
error_pattern: str,
|
|
) -> None:
|
|
fixture = prepare_fixture(monkeypatch, tmp_path, existing_config=source)
|
|
|
|
with pytest.raises(ValueError, match=error_pattern):
|
|
fixture.install()
|
|
|
|
assert fixture.config_path.read_text(encoding="utf-8") == source
|
|
assert list(fixture.agent_dir.iterdir()) == []
|
|
|
|
|
|
def test_status_requires_exact_pair_identity_and_runtime_secrets(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
|
fixture.install()
|
|
assert fixture.plugin.status()["installed"] is True
|
|
|
|
fixture.private_key_path.unlink()
|
|
status = fixture.plugin.status()
|
|
assert status["installed"] is False
|
|
assert "runtime files missing" in status["details"]
|
|
|
|
fixture.install()
|
|
settings = fixture.settings()
|
|
guard = managed_handler(settings, "PreToolUse", GUARD_STATUS)
|
|
guard["extra"] = True
|
|
write_json(fixture.config_path, settings)
|
|
assert fixture.plugin.status()["installed"] is False
|
|
|
|
fixture.install()
|
|
runtime = json.loads(fixture.runtime_config_path.read_text(encoding="utf-8"))
|
|
runtime["agent_id"] = "another-agent"
|
|
write_json(fixture.runtime_config_path, runtime)
|
|
assert fixture.plugin.status()["installed"] is False
|
|
|
|
write_text(fixture.runtime_config_path, "{ malformed")
|
|
with pytest.raises(ValueError, match="parse Elydora runtime config"):
|
|
fixture.plugin.status()
|
|
|
|
|
|
def test_uninstall_removes_exact_ownership_and_preserves_lookalikes(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
|
fixture.install()
|
|
settings = fixture.settings()
|
|
agent_ten = fixture.agent_dir.parent / "agent-10"
|
|
other_guard = build_handler(str(agent_ten / "guard.py"), GUARD_STATUS)
|
|
other_audit = build_handler(str(agent_ten / "hook.py"), AUDIT_STATUS)
|
|
lookalike = {
|
|
"type": "command",
|
|
"command": f"inspect {fixture.guard_path}",
|
|
"commandWindows": f"inspect {fixture.guard_path}",
|
|
"timeout": 10,
|
|
"statusMessage": GUARD_STATUS,
|
|
}
|
|
managed_guard = dict(managed_handler(settings, "PreToolUse", GUARD_STATUS))
|
|
settings["hooks"]["SessionStart"] = [
|
|
{"matcher": "*", "hooks": [{"type": "command", "command": "keep"}]}
|
|
]
|
|
settings["hooks"]["PreToolUse"].extend(
|
|
[
|
|
{"matcher": "*", "hooks": [other_guard]},
|
|
{"matcher": "*", "hooks": [lookalike]},
|
|
{"matcher": "Bash", "hooks": [managed_guard]},
|
|
]
|
|
)
|
|
settings["hooks"]["PostToolUse"].append({"matcher": "*", "hooks": [other_audit]})
|
|
write_json(fixture.config_path, settings)
|
|
|
|
fixture.plugin.uninstall(AGENT_ID)
|
|
|
|
remaining = fixture.settings()
|
|
assert remaining["hooks"]["SessionStart"][0]["hooks"][0]["command"] == "keep"
|
|
assert len(remaining["hooks"]["PreToolUse"]) == 3
|
|
assert len(remaining["hooks"]["PostToolUse"]) == 1
|
|
assert remaining["hooks"]["PreToolUse"][1]["hooks"][0] == lookalike
|
|
assert remaining["hooks"]["PreToolUse"][2] == {"matcher": "Bash", "hooks": []}
|
|
|
|
|
|
def test_uninstall_removes_an_entirely_managed_file(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
|
fixture.install()
|
|
|
|
fixture.plugin.uninstall(AGENT_ID)
|
|
fixture.plugin.uninstall(AGENT_ID)
|
|
|
|
assert fixture.config_path.exists() is False
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"overrides",
|
|
[
|
|
{"agent_name": "cursor"},
|
|
{"org_id": ""},
|
|
{"agent_id": "../escape"},
|
|
{"private_key": "invalid"},
|
|
{"token": ""},
|
|
{"base_url": "file:///tmp/elydora"},
|
|
{"base_url": "https://user:secret@api.elydora.com"},
|
|
{"base_url": "https://api.elydora.com?tenant=one"},
|
|
{"base_url": "https://api.elydora.com\\evil"},
|
|
{"base_url": "https://api.elydora.com/invalid path"},
|
|
{"base_url": "https://api.elydora.com:invalid"},
|
|
{"guard_script_path": "unmanaged.py"},
|
|
],
|
|
)
|
|
def test_install_validates_identity_credentials_and_api_origin(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
overrides: dict[str, str],
|
|
) -> None:
|
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
|
fixture.config.update(overrides)
|
|
|
|
with pytest.raises(ValueError):
|
|
fixture.install()
|
|
|
|
assert fixture.config_path.exists() is False
|
|
assert list(fixture.agent_dir.iterdir()) == []
|
|
|
|
|
|
@pytest.mark.parametrize("kind", ["hooks", "config", "key", "guard", "audit"])
|
|
def test_linked_hook_and_runtime_files_are_rejected(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
kind: str,
|
|
) -> None:
|
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
|
if kind == "hooks":
|
|
target = tmp_path / "hooks-target.json"
|
|
original = '{"hooks":{}}\n'
|
|
write_text(target, original)
|
|
fixture.config_path.parent.mkdir(parents=True)
|
|
symlink_or_skip(target, fixture.config_path)
|
|
with pytest.raises(OSError, match="physical file"):
|
|
fixture.install()
|
|
assert target.read_text(encoding="utf-8") == original
|
|
return
|
|
|
|
fixture.install()
|
|
file_path = {
|
|
"config": fixture.runtime_config_path,
|
|
"key": fixture.private_key_path,
|
|
"guard": fixture.guard_path,
|
|
"audit": fixture.hook_path,
|
|
}[kind]
|
|
target = tmp_path / f"{kind}-target"
|
|
target.write_bytes(file_path.read_bytes())
|
|
file_path.unlink()
|
|
symlink_or_skip(target, file_path)
|
|
|
|
with pytest.raises(OSError, match="physical file"):
|
|
fixture.plugin.status()
|
|
|
|
assert target.read_bytes()
|