glove/tests/kernel/server_test.cpp
2026-07-19 21:06:11 -04:00

172 lines
6.4 KiB
C++

// End-to-end kernel test: drive a server reading from the kernel side of a
// mock_pair, while the test acts as the agent on the other side. Validates
// the request/response loop, policy gating, and audit recording.
#include "glove/audit/event.hpp"
#include "glove/audit/sink.hpp"
#include "glove/kernel/extension.hpp"
#include "glove/kernel/registry.hpp"
#include "glove/kernel/server.hpp"
#include "glove/mcp/messages.hpp"
#include "glove/policy/decision.hpp"
#include "glove/policy/engine.hpp"
#include "tests/kernel/mock_pair.hpp"
#include <cstdio>
#include <expected>
#include <memory>
#include <string>
#include <string_view>
#include <thread>
#include <vector>
namespace {
#define REQUIRE(cond) \
do { \
if (!(cond)) { \
std::fprintf(stderr, "REQUIRE failed: %s @ %s:%d\n", #cond, __FILE__, __LINE__); \
return 1; \
} \
} while (0)
class echo_extension final : public glove::kernel::extension {
public:
auto name() const -> std::string_view override { return "echo"; }
auto tools() -> std::expected<std::vector<glove::mcp::tool_descriptor>, std::string> override {
return std::vector<glove::mcp::tool_descriptor>{
{.name = "say",
.description = "Echo the text argument back as content.",
.input_schema_json = R"({"type":"object","properties":{"text":{"type":"string"}}})",
.annotations = {}},
{.name = "secret",
.description = "Must not be advertised when policy denies it.",
.input_schema_json = R"({"type":"object"})",
.annotations = {}},
};
}
auto invoke(std::string_view tool_name, std::string_view arguments_json)
-> std::expected<glove::mcp::tool_call_result, std::string> override {
if (tool_name != "say") {
return std::unexpected(std::string{"unknown tool"});
}
return glove::mcp::tool_call_result{
.status = glove::mcp::tool_call_status::ok,
.content = "echoed",
.structured_json = std::string{arguments_json},
.error_message = "",
};
}
};
auto run() -> int {
glove::testing::mock_pair pair;
glove::kernel::registry reg;
REQUIRE(reg.add(std::make_unique<echo_extension>()).has_value());
// Policy operates on the qualified tool name as the agent sees it.
auto policy_eng = glove::policy::make_allow_list_engine(
{.allow = {"echo.say"},
.deny = {"echo.forbidden"},
.default_decision = glove::policy::decision::deny}
);
auto sink = glove::audit::make_memory_sink();
glove::kernel::server::options opts{
.identity = {.name = "glove-test", .version = "0.0.1"},
.policy = std::move(policy_eng),
.audit = sink,
};
glove::kernel::server srv{pair.kernel_side(), reg, std::move(opts)};
std::thread server_thread{[&] { (void)srv.run(); }};
auto& agent = pair.agent_side();
// initialize
REQUIRE(
agent
.send(
R"({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"0"}}})"
)
.has_value()
);
auto reply = agent.recv();
REQUIRE(reply.has_value());
REQUIRE(reply->find("\"id\":1") != std::string::npos);
REQUIRE(reply->find("\"name\":\"glove-test\"") != std::string::npos);
REQUIRE(reply->find("2025-06-18") != std::string::npos);
// tools/list
REQUIRE(agent.send(R"({"jsonrpc":"2.0","id":2,"method":"tools/list"})").has_value());
reply = agent.recv();
REQUIRE(reply.has_value());
REQUIRE(reply->find("\"id\":2") != std::string::npos);
REQUIRE(reply->find("\"name\":\"echo.say\"") != std::string::npos);
REQUIRE(reply->find("echo.secret") == std::string::npos);
// tools/call allowed
REQUIRE(
agent
.send(
R"({"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"echo.say","arguments":{"text":"hi"}}})"
)
.has_value()
);
reply = agent.recv();
REQUIRE(reply.has_value());
REQUIRE(reply->find("\"id\":3") != std::string::npos);
REQUIRE(reply->find("\"text\":\"hi\"") != std::string::npos);
// tools/call denied by policy (echo.forbidden does not exist as a tool,
// but it doesn't matter — policy denies before dispatch; we just need a
// name that's not in the allow list).
REQUIRE(
agent
.send(
R"({"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"echo.unknown_tool","arguments":{}}})"
)
.has_value()
);
reply = agent.recv();
REQUIRE(reply.has_value());
REQUIRE(reply->find("\"id\":4") != std::string::npos);
REQUIRE(reply->find("policy denied") != std::string::npos);
// unknown method
REQUIRE(agent.send(R"({"jsonrpc":"2.0","id":5,"method":"resources/list"})").has_value());
reply = agent.recv();
REQUIRE(reply.has_value());
REQUIRE(reply->find("\"id\":5") != std::string::npos);
REQUIRE(reply->find("-32601") != std::string::npos);
// notification: no response should appear.
REQUIRE(agent.send(R"({"jsonrpc":"2.0","method":"notifications/initialized"})").has_value());
// Close the agent → kernel direction so the server exits.
pair.close_agent_to_kernel();
server_thread.join();
auto events = sink->take();
// Expected: initialize, tools/list, tools/call OK, tools/call DENIED.
REQUIRE(events.size() >= 4);
REQUIRE(events[0].what == glove::audit::action::initialize);
REQUIRE(events[1].what == glove::audit::action::list_tools);
REQUIRE(events[2].what == glove::audit::action::call_tool);
REQUIRE(events[2].status == glove::mcp::tool_call_status::ok);
REQUIRE(events[3].what == glove::audit::action::call_tool);
REQUIRE(events[3].status == glove::mcp::tool_call_status::invalid_arguments);
REQUIRE(events[3].error_message.find("policy denied") != std::string::npos);
return 0;
}
} // namespace
auto main() -> int {
return run();
}