mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-04 10:13:30 +00:00
Adds first-class support for xAI's Grok Build CLI, following the
existing client-integration pattern (Claude Code, CodeBuddy, Codex):
- grok context: single-project, shaped like claude-code.yml, with the
file-tool exclusions appropriate for a CLI agent that has its own
read/edit/shell tools. structured_tool_output is left at the auto
default (as in the codex and codebuddy contexts); claude-code's
explicit false is a Claude-Code-specific bug workaround that is not
carried over.
- serena setup grok: registers Serena as a user-scoped MCP server via
"grok mcp add" after detecting a compatible grok CLI.
- serena-hooks --client=grok: Grok-native PreToolUse output
({decision, reason}) instead of the Claude-style hookSpecificOutput
envelope; grep/read detection extended to Grok's tool names and
shell payloads (target_file/targetFile path keys, non-string command
values handled defensively). The payload-parsing refinements live in
the remind hook's client-agnostic parsing and apply to all hook
clients: a non-string command value previously raised, and
target_file/targetFile paths were not recognized.
- Docs (030_clients.md #grok), unit tests for setup/hooks/context, and
scripts/live_test_grok.py - a zero-inference, state-preserving live
smoke test against a real grok installation (documented in
CONTRIBUTING.md). Its M5 check verifies the structured-output wire
shape: the grok context (auto default) serves an output schema and
structuredContent for string-returning tools, while the claude-code
context (explicit false workaround) serves plain text only.
1195 lines
60 KiB
Python
1195 lines
60 KiB
Python
"""
|
|
Live smoke test for Serena's Grok Build integration.
|
|
|
|
Verifies that the Grok support (``serena setup grok``, the ``grok`` context and the native
|
|
``serena-hooks --client=grok`` protocol) works against a *real* ``grok`` CLI installation —
|
|
exercising the exact code paths that the unit tests cover with mocks
|
|
(``test/serena/config/test_client_setup.py``, ``test/serena/test_hooks.py``, ...).
|
|
|
|
The test is designed to be safe and free:
|
|
|
|
* **Zero inference cost.** No Grok session is ever started and no prompt is sent; only
|
|
configuration, discovery, stdio hook invocations and raw MCP handshakes (including one
|
|
read-only Serena tool call) are used.
|
|
* **State-preserving.** The Grok user configuration is backed up before the first mutation,
|
|
every ``grok mcp add`` is paired with a removal, and the baseline is verified (and restored
|
|
from backup if necessary) at the end — also on abort. Hook state is written to an isolated,
|
|
temporary ``SERENA_HOME``. The script refuses to run if a ``serena`` MCP server is already
|
|
registered in Grok, so it never clobbers a real setup.
|
|
* **Credential-safe.** The config backup (which may carry tokens) is written 0600 inside a private,
|
|
owner-only work directory (a fresh ``mkdtemp`` by default; a supplied ``--work-dir`` is validated
|
|
as non-symlink, owner-owned and not group/world-accessible) and is deleted once the baseline is
|
|
confirmed intact. ``--hooks-only`` never mutates the config and therefore never backs it up.
|
|
|
|
Per-check evidence files and a Markdown report are written to the work directory (printed at
|
|
startup; default: a fresh private 0700 directory created via ``mkdtemp`` under the system tmp,
|
|
prefix ``serena-grok-live-``). The exit code is 0 iff no check FAILed.
|
|
|
|
Usage::
|
|
|
|
uv run python scripts/live_test_grok.py # full run
|
|
uv run python scripts/live_test_grok.py --hooks-only # pure-local checks only (no Grok config changes)
|
|
uv run python scripts/live_test_grok.py --skip-unit # skip the pytest smoke run
|
|
uv run python scripts/live_test_grok.py --help # all options
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import queue
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from enum import Enum
|
|
from pathlib import Path
|
|
from typing import IO, Optional
|
|
|
|
CONTEXT_EXCLUDED_TOOLS = ("create_text_file", "execute_shell_command", "find_file", "list_dir", "read_file", "search_for_pattern")
|
|
"""tools excluded by the ``grok`` context (mirrors ``src/serena/resources/config/contexts/grok.yml``)"""
|
|
|
|
REQUIRED_SYMBOLIC_TOOLS = ("find_symbol", "get_symbols_overview", "replace_symbol_body", "find_referencing_symbols")
|
|
"""symbolic tools that must be exposed by the MCP server in project mode"""
|
|
|
|
UNIT_TEST_FILES = (
|
|
"test/serena/test_hooks.py",
|
|
"test/serena/test_cli_setup.py",
|
|
"test/serena/config/test_client_setup.py",
|
|
"test/serena/config/test_context_mode.py",
|
|
"test/serena/config/test_grok_docs.py",
|
|
)
|
|
"""unit-test files whose mocked assumptions this live test validates"""
|
|
|
|
LIVE_MCP_SERVER_NAME = "serena-live"
|
|
"""name of the temporary MCP server entry registered for connectivity checks"""
|
|
|
|
STRUCTURED_OUTPUT_PROBE_TOOL = "initial_instructions"
|
|
"""read-only project-mode tool (returns a plain string) used to probe the structured-output wire shape"""
|
|
|
|
HOOKS_JSON = {
|
|
"hooks": {
|
|
"PreToolUse": [
|
|
{
|
|
"matcher": "grep|read_file|run_terminal_command",
|
|
"hooks": [{"type": "command", "command": "serena-hooks remind --client=grok", "timeout": 5}],
|
|
}
|
|
],
|
|
"Stop": [{"hooks": [{"type": "command", "command": "serena-hooks cleanup --client=grok", "timeout": 5}]}],
|
|
}
|
|
}
|
|
"""the hooks configuration recommended in docs/02-usage/030_clients.md (Grok section)"""
|
|
|
|
|
|
class Status(Enum):
|
|
"""The outcome category of a single live check."""
|
|
|
|
PASS = "PASS"
|
|
FAIL = "FAIL"
|
|
WARN = "WARN"
|
|
INFO = "INFO"
|
|
SKIP = "SKIP"
|
|
|
|
|
|
@dataclass
|
|
class CheckResult:
|
|
"""The recorded outcome of a single live check."""
|
|
|
|
check_id: str
|
|
status: Status
|
|
note: str
|
|
|
|
|
|
@dataclass
|
|
class CommandResult:
|
|
"""The captured outcome of an executed subprocess."""
|
|
|
|
return_code: Optional[int]
|
|
stdout: str
|
|
stderr: str
|
|
timed_out: bool = False
|
|
|
|
@property
|
|
def output(self) -> str:
|
|
return self.stdout + ("\n" + self.stderr if self.stderr else "")
|
|
|
|
|
|
class AbortError(Exception):
|
|
"""Raised when the environment makes continuing pointless or unsafe."""
|
|
|
|
|
|
def run_command(
|
|
argv: list[str],
|
|
timeout: float = 60,
|
|
cwd: Optional[Path] = None,
|
|
env_overlay: Optional[dict[str, str]] = None,
|
|
stdin_text: Optional[str] = None,
|
|
) -> CommandResult:
|
|
"""
|
|
Runs a subprocess without a shell, capturing output and never raising on non-zero exit.
|
|
|
|
:param argv: the command and its arguments
|
|
:param timeout: seconds after which the process is killed
|
|
:param cwd: the working directory for the process
|
|
:param env_overlay: environment variables to add on top of the current environment
|
|
:param stdin_text: text to pass on standard input
|
|
:return: the captured result; on timeout, ``timed_out`` is set and ``return_code`` is None
|
|
"""
|
|
env = None
|
|
if env_overlay is not None:
|
|
env = {**os.environ, **env_overlay}
|
|
try:
|
|
completed = subprocess.run(argv, capture_output=True, text=True, timeout=timeout, cwd=cwd, env=env, input=stdin_text, check=False)
|
|
return CommandResult(completed.returncode, completed.stdout, completed.stderr)
|
|
except subprocess.TimeoutExpired as e:
|
|
stdout = e.stdout.decode() if isinstance(e.stdout, bytes) else (e.stdout or "")
|
|
stderr = e.stderr.decode() if isinstance(e.stderr, bytes) else (e.stderr or "")
|
|
return CommandResult(None, stdout, stderr, timed_out=True)
|
|
except FileNotFoundError:
|
|
return CommandResult(127, "", f"executable not found: {argv[0]}")
|
|
|
|
|
|
class McpStdioProbe:
|
|
"""
|
|
A minimal MCP client that spawns a stdio server, performs the initialize handshake and
|
|
lists the exposed tools. Uses a reader thread (rather than ``select``) for portability.
|
|
"""
|
|
|
|
def __init__(self, argv: list[str], cwd: Path, stderr_log: IO[str]) -> None:
|
|
"""
|
|
:param argv: the server command line
|
|
:param cwd: the working directory for the server process
|
|
:param stderr_log: an open file receiving the server's stderr (Serena logs to stderr)
|
|
"""
|
|
self._process = subprocess.Popen(
|
|
argv, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=stderr_log, text=True, bufsize=1, cwd=cwd
|
|
)
|
|
self._messages: queue.Queue[dict] = queue.Queue()
|
|
self._reader = threading.Thread(target=self._read_loop, daemon=True)
|
|
self._reader.start()
|
|
|
|
def _read_loop(self) -> None:
|
|
assert self._process.stdout is not None
|
|
for line in self._process.stdout:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
self._messages.put(json.loads(line))
|
|
except json.JSONDecodeError:
|
|
continue
|
|
|
|
def _send(self, message: dict) -> None:
|
|
assert self._process.stdin is not None
|
|
self._process.stdin.write(json.dumps(message) + "\n")
|
|
self._process.stdin.flush()
|
|
|
|
def request(self, request_id: int, method: str, params: dict, timeout: float) -> dict:
|
|
"""
|
|
Sends a JSON-RPC request and waits for the response with the matching id.
|
|
|
|
:param request_id: the JSON-RPC id to send and match on
|
|
:param method: the JSON-RPC method name
|
|
:param params: the JSON-RPC params object
|
|
:param timeout: seconds to wait before giving up
|
|
:return: the raw response message
|
|
:raises AbortError: if the server exits prematurely or the response does not arrive in time
|
|
"""
|
|
self._send({"jsonrpc": "2.0", "id": request_id, "method": method, "params": params})
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
if self._process.poll() is not None:
|
|
raise AbortError(f"MCP server exited prematurely with code {self._process.returncode}")
|
|
try:
|
|
message = self._messages.get(timeout=1.0)
|
|
except queue.Empty:
|
|
continue
|
|
if message.get("id") == request_id:
|
|
return message
|
|
raise AbortError(f"timed out waiting for MCP response to '{method}'")
|
|
|
|
def notify(self, method: str) -> None:
|
|
self._send({"jsonrpc": "2.0", "method": method, "params": {}})
|
|
|
|
def close(self) -> None:
|
|
self._process.terminate()
|
|
try:
|
|
self._process.wait(timeout=10)
|
|
except subprocess.TimeoutExpired:
|
|
self._process.kill()
|
|
|
|
|
|
@dataclass
|
|
class LiveTestConfig:
|
|
"""The resolved configuration of a live test run."""
|
|
|
|
repo_root: Path
|
|
serena_bin: Path
|
|
serena_hooks_bin: Path
|
|
python_bin: Path
|
|
grok_bin: str
|
|
grok_config_path: Path
|
|
work_dir: Path
|
|
hooks_only: bool = False
|
|
skip_unit: bool = False
|
|
grok_config_explicit: bool = False
|
|
"""whether --grok-config was supplied on the command line (enables the config-path/CLI consistency check)"""
|
|
|
|
@property
|
|
def evidence_dir(self) -> Path:
|
|
return self.work_dir / "evidence"
|
|
|
|
@property
|
|
def hook_home(self) -> Path:
|
|
"""An isolated ``SERENA_HOME`` so hook state never touches the user's real one"""
|
|
return self.work_dir / "serena-home"
|
|
|
|
@property
|
|
def config_backup_path(self) -> Path:
|
|
return self.work_dir / "config.toml.bak"
|
|
|
|
@staticmethod
|
|
def _venv_executable(repo_root: Path, name: str) -> Path:
|
|
if sys.platform == "win32":
|
|
return repo_root / ".venv" / "Scripts" / f"{name}.exe"
|
|
return repo_root / ".venv" / "bin" / name
|
|
|
|
@classmethod
|
|
def from_args(cls, args: argparse.Namespace) -> "LiveTestConfig":
|
|
"""
|
|
:param args: the parsed command-line arguments
|
|
:return: the resolved configuration, with defaults derived from the repository layout
|
|
:raises AbortError: if a required executable cannot be resolved
|
|
"""
|
|
repo_root = Path(args.repo_root).resolve() if args.repo_root else Path(__file__).resolve().parents[1]
|
|
|
|
# resolve the executables under test, defaulting to the repository's venv
|
|
serena_bin = Path(args.serena_bin) if args.serena_bin else cls._venv_executable(repo_root, "serena")
|
|
serena_hooks_bin = Path(args.serena_hooks_bin) if args.serena_hooks_bin else cls._venv_executable(repo_root, "serena-hooks")
|
|
python_bin = cls._venv_executable(repo_root, "python")
|
|
for executable in (serena_bin, serena_hooks_bin, python_bin):
|
|
if not executable.exists():
|
|
raise AbortError(f"executable not found: {executable} — run 'uv sync' first (or pass --serena-bin/--serena-hooks-bin)")
|
|
|
|
# resolve the grok CLI and its user-level configuration file
|
|
grok_bin = args.grok_bin or shutil.which("grok") or ""
|
|
if not grok_bin:
|
|
raise AbortError("the 'grok' CLI was not found on PATH (override with --grok-bin)")
|
|
grok_config_path = Path(args.grok_config) if args.grok_config else Path.home() / ".grok" / "config.toml"
|
|
|
|
# Default to a private, unpredictable work dir (0700, owner-only): it holds a backup of the
|
|
# user's grok config, which may carry credentials. A fixed shared-tmp path would expose those
|
|
# to other local users and invites symlink/pre-creation attacks.
|
|
work_dir = Path(args.work_dir) if args.work_dir else Path(tempfile.mkdtemp(prefix="serena-grok-live-"))
|
|
return cls(
|
|
repo_root=repo_root,
|
|
serena_bin=serena_bin,
|
|
serena_hooks_bin=serena_hooks_bin,
|
|
python_bin=python_bin,
|
|
grok_bin=grok_bin,
|
|
grok_config_path=grok_config_path,
|
|
work_dir=work_dir,
|
|
hooks_only=args.hooks_only,
|
|
skip_unit=args.skip_unit,
|
|
grok_config_explicit=bool(args.grok_config),
|
|
)
|
|
|
|
|
|
class GrokLiveTest:
|
|
"""Orchestrates the live checks, evidence capture, cleanup and reporting."""
|
|
|
|
def __init__(self, config: LiveTestConfig) -> None:
|
|
self.config = config
|
|
self.results: list[CheckResult] = []
|
|
self.findings: list[str] = []
|
|
self._registered_server_names: list[str] = []
|
|
self._created_global_hooks_file: Optional[Path] = None
|
|
self._cleanup_done = False
|
|
self._baseline_mcp_list = ""
|
|
self._environment_summary = ""
|
|
|
|
# ------------------------------------------------------------------ infrastructure
|
|
|
|
def _record(self, check_id: str, status: Status, note: str) -> None:
|
|
self.results.append(CheckResult(check_id, status, note))
|
|
print(f"[{status.value}] {check_id} — {note}")
|
|
|
|
def _finding(self, text: str) -> None:
|
|
self.findings.append(text)
|
|
print(f"FINDING: {text}")
|
|
|
|
@staticmethod
|
|
def _section(title: str) -> None:
|
|
print(f"\n=== {title} ===")
|
|
|
|
def _write_evidence(self, name: str, content: str) -> None:
|
|
# Evidence can embed other MCP servers' env/args (a common place for tokens); keep it owner-only.
|
|
path = self.config.evidence_dir / name
|
|
path.write_text(content, encoding="utf-8")
|
|
os.chmod(path, 0o600)
|
|
|
|
def _open_evidence(self, name: str) -> IO[str]:
|
|
"""Open an evidence file for writing with owner-only (0600) permissions (e.g. server stderr logs)."""
|
|
path = self.config.evidence_dir / name
|
|
path.touch(mode=0o600)
|
|
return open(path, "w", encoding="utf-8")
|
|
|
|
def _grok(self, *args: str, timeout: float = 60) -> CommandResult:
|
|
return run_command([self.config.grok_bin, *args], timeout=timeout)
|
|
|
|
def _run_hook(self, subcommand: str, payload: dict) -> CommandResult:
|
|
argv = [str(self.config.serena_hooks_bin), subcommand, "--client", "grok"]
|
|
return run_command(argv, timeout=30, env_overlay={"SERENA_HOME": str(self.config.hook_home)}, stdin_text=json.dumps(payload))
|
|
|
|
@staticmethod
|
|
def _pre_tool_use_payload(session_id: str, tool_name: str, tool_input: dict, permission_mode: str = "bypassPermissions") -> dict:
|
|
""":return: a PreToolUse payload in Grok's camelCase envelope (mirrors ``test_hooks.py::_grok_input``)"""
|
|
return {
|
|
"hookEventName": "pre_tool_use",
|
|
"sessionId": session_id,
|
|
"toolName": tool_name,
|
|
"toolInput": tool_input,
|
|
"toolInputTruncated": False,
|
|
"permissionMode": permission_mode,
|
|
}
|
|
|
|
def _read_grok_mcp_server_section(self, server_name: str) -> dict:
|
|
""":return: the ``[mcp_servers.<server_name>]`` table from the Grok configuration, or an empty dict"""
|
|
import tomllib
|
|
|
|
try:
|
|
with open(self.config.grok_config_path, "rb") as f:
|
|
config = tomllib.load(f)
|
|
return config.get("mcp_servers", {}).get(server_name, {})
|
|
except (OSError, tomllib.TOMLDecodeError):
|
|
return {}
|
|
|
|
def _verify_config_path_matches_cli(self, mcp_list_output: str) -> None:
|
|
"""Abort if ``--grok-config`` points at a file the grok CLI is not actually using.
|
|
|
|
The grok CLI offers no alternate-config option (``grok mcp`` knows only the user/project
|
|
scopes), so ``--grok-config`` exists solely for nonstandard Grok homes. If it named a file
|
|
the CLI does not use, every file-level safety measure in this script (backup, restore,
|
|
baseline byte-comparison) would silently protect the wrong file while ``grok mcp
|
|
add/remove`` mutates the real configuration. Cross-check: the ``[mcp_servers.*]`` table
|
|
names in the file must equal the server names reported by ``grok mcp list``.
|
|
|
|
:param mcp_list_output: the raw output of ``grok mcp list`` captured for the baseline
|
|
:raises AbortError: on any mismatch, before any mutation has occurred
|
|
"""
|
|
if not self.config.grok_config_explicit:
|
|
return
|
|
import tomllib
|
|
|
|
try:
|
|
with open(self.config.grok_config_path, "rb") as f:
|
|
file_servers = set(tomllib.load(f).get("mcp_servers", {}).keys())
|
|
except (OSError, tomllib.TOMLDecodeError):
|
|
file_servers = set()
|
|
import re
|
|
|
|
cli_servers = set()
|
|
for line in mcp_list_output.splitlines():
|
|
m = re.match(r"^\s*([\w-]+):\s", line)
|
|
if m:
|
|
cli_servers.add(m.group(1))
|
|
if file_servers != cli_servers:
|
|
self._record("P3", Status.FAIL, "--grok-config does not match the configuration the grok CLI actually uses")
|
|
raise AbortError(
|
|
f"--grok-config {self.config.grok_config_path} is not the config the grok CLI uses "
|
|
f"(file declares mcp_servers {sorted(file_servers)}, 'grok mcp list' reports {sorted(cli_servers)}). "
|
|
"This flag is for nonstandard Grok homes only — it cannot isolate the test from the real "
|
|
"configuration, because 'grok mcp' has no alternate-config option. Aborting before any mutation."
|
|
)
|
|
|
|
def _insert_startup_timeout_sec(self, seconds: int) -> bool:
|
|
"""Add ``startup_timeout_sec`` to the live server's ``[mcp_servers.<name>]`` table, located structurally.
|
|
|
|
The table is confirmed to exist (and to lack the key) by parsing the config with ``tomllib``, then the
|
|
key is inserted immediately after the exact table-header line. This avoids a blind substring replace,
|
|
which could match ``[mcp_servers.<name>]`` inside a comment or string value elsewhere in the user's
|
|
config and corrupt it. The rest of the file (comments, formatting) is preserved by editing in place.
|
|
|
|
:return: True if the key was inserted, False if the table is absent, already has the key, or is unparsable.
|
|
"""
|
|
section = f"[mcp_servers.{LIVE_MCP_SERVER_NAME}]"
|
|
existing = self._read_grok_mcp_server_section(LIVE_MCP_SERVER_NAME)
|
|
if not existing or "startup_timeout_sec" in existing:
|
|
return False
|
|
lines = self.config.grok_config_path.read_text(encoding="utf-8").splitlines()
|
|
for i, line in enumerate(lines):
|
|
if line.strip() == section:
|
|
lines.insert(i + 1, f"startup_timeout_sec = {seconds}")
|
|
self.config.grok_config_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
return True
|
|
return False
|
|
|
|
def _cleanup_grok_state(self) -> None:
|
|
"""Removes every Grok-side artifact this run created (idempotent, best-effort)."""
|
|
for server_name in list(self._registered_server_names):
|
|
self._grok("mcp", "remove", "--scope", "user", server_name)
|
|
self._registered_server_names.remove(server_name)
|
|
if self._created_global_hooks_file is not None:
|
|
self._created_global_hooks_file.unlink(missing_ok=True)
|
|
try:
|
|
self._created_global_hooks_file.parent.rmdir()
|
|
except OSError:
|
|
pass
|
|
self._created_global_hooks_file = None
|
|
|
|
# ------------------------------------------------------------------ phase P: preflight
|
|
|
|
def check_p1_environment(self) -> None:
|
|
self._section("P1 — repository, binaries, local-source resolution")
|
|
|
|
# gather the facts that every later check relies on
|
|
branch = run_command(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=self.config.repo_root).stdout.strip()
|
|
commit = run_command(["git", "log", "--oneline", "-1"], cwd=self.config.repo_root).stdout.strip()
|
|
grok_version = self._grok("--version").stdout.strip()
|
|
module_path = run_command([str(self.config.python_bin), "-c", "import serena; print(serena.__file__)"]).stdout.strip()
|
|
setup_help = run_command([str(self.config.serena_bin), "setup", "--help"]).output
|
|
remind_help = run_command([str(self.config.serena_hooks_bin), "remind", "--help"]).output
|
|
self._environment_summary = f"branch {branch} ({commit}); {grok_version}"
|
|
|
|
# validate that the grok CLI works and the serena under test actually has Grok support
|
|
problems = []
|
|
if not grok_version.lower().startswith("grok "):
|
|
problems.append(f"grok CLI not functional: {grok_version!r}")
|
|
expected_module = self.config.repo_root / "src" / "serena" / "__init__.py"
|
|
if Path(module_path) != expected_module:
|
|
problems.append(f"serena resolves to {module_path}, not the repository source ({expected_module}) — run 'uv sync'")
|
|
if "grok" not in setup_help:
|
|
problems.append("'serena setup' does not offer the grok client — is Grok support present on this branch?")
|
|
if "grok" not in remind_help:
|
|
problems.append("'serena-hooks remind --client' does not offer grok")
|
|
|
|
if problems:
|
|
self._record("P1", Status.FAIL, "; ".join(problems))
|
|
raise AbortError("environment not usable for the live test")
|
|
self._record("P1", Status.PASS, self._environment_summary)
|
|
|
|
@staticmethod
|
|
def _require_private_dir(path: Path) -> None:
|
|
"""Ensure ``path`` is a real, owner-owned, non-group/world-accessible directory.
|
|
|
|
Guards a user-supplied --work-dir before any credential-bearing backup is written into it,
|
|
and rejects a symlink or another user's pre-created directory.
|
|
"""
|
|
info = path.lstat()
|
|
import stat as _stat
|
|
|
|
if _stat.S_ISLNK(info.st_mode):
|
|
raise AbortError(f"work dir {path} is a symlink; refusing to write a config backup through it")
|
|
if not path.is_dir():
|
|
raise AbortError(f"work dir {path} exists but is not a directory")
|
|
if info.st_uid != os.getuid():
|
|
raise AbortError(f"work dir {path} is not owned by the current user; refusing to use it")
|
|
if info.st_mode & (_stat.S_IRWXG | _stat.S_IRWXO):
|
|
raise AbortError(f"work dir {path} is group/world-accessible; run 'chmod 700 {path}' or omit --work-dir")
|
|
|
|
def _backup_config_private(self) -> None:
|
|
"""Copy the grok config into the work dir with 0600 from creation (no world-readable window)."""
|
|
dst = self.config.config_backup_path
|
|
content = self.config.grok_config_path.read_bytes() if self.config.grok_config_path.exists() else b""
|
|
fd = os.open(str(dst), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
with os.fdopen(fd, "wb") as out:
|
|
out.write(content)
|
|
|
|
def check_p3_baseline(self) -> None:
|
|
self._section("P3 — work directory, baseline snapshot, config backup")
|
|
|
|
# The work dir holds a backup of the possibly-credential-bearing grok config, so it must be
|
|
# private. A default work dir is a fresh 0700 mkdtemp; a user-supplied one is validated.
|
|
if self.config.work_dir.exists():
|
|
self._require_private_dir(self.config.work_dir)
|
|
else:
|
|
self.config.work_dir.mkdir(mode=0o700, parents=True)
|
|
self.config.evidence_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
self.config.hook_home.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
|
|
# Back up the grok configuration before anything may mutate it — but only when we might mutate
|
|
# it. --hooks-only never touches the config, so it must not copy the credentials at all.
|
|
if self.config.hooks_only:
|
|
self._baseline_mcp_list = self._grok("mcp", "list").output
|
|
self._write_evidence("P3-baseline-mcp-list.txt", self._baseline_mcp_list)
|
|
self._record("P3", Status.PASS, "hooks-only: baseline captured, config left untouched (no backup)")
|
|
return
|
|
|
|
self._backup_config_private()
|
|
|
|
# snapshot the MCP baseline and refuse to run against a pre-existing serena registration
|
|
baseline = self._grok("mcp", "list")
|
|
self._baseline_mcp_list = baseline.output
|
|
self._write_evidence("P3-baseline-mcp-list.txt", self._baseline_mcp_list)
|
|
print(self._baseline_mcp_list.strip())
|
|
self._verify_config_path_matches_cli(self._baseline_mcp_list)
|
|
if "serena" in self._baseline_mcp_list:
|
|
self._record("P3", Status.FAIL, "a serena MCP entry is already registered in Grok — refusing to touch it")
|
|
raise AbortError(
|
|
"remove it first if it is a leftover: grok mcp remove --scope user serena "
|
|
f"(and/or {LIVE_MCP_SERVER_NAME}); if it is your real setup, run with --hooks-only instead"
|
|
)
|
|
self._record("P3", Status.PASS, f"baseline captured, config backed up to {self.config.config_backup_path}")
|
|
|
|
def check_p2_unit_smoke(self) -> None:
|
|
self._section("P2 — unit-test smoke (anchors live results to the mocked assumptions)")
|
|
if self.config.skip_unit:
|
|
self._record("P2", Status.SKIP, "--skip-unit")
|
|
return
|
|
|
|
result = run_command([str(self.config.python_bin), "-m", "pytest", *UNIT_TEST_FILES, "-q"], timeout=900, cwd=self.config.repo_root)
|
|
agent_result = run_command(
|
|
[str(self.config.python_bin), "-m", "pytest", "test/serena/test_serena_agent.py", "-k", "grok", "-q"],
|
|
timeout=900,
|
|
cwd=self.config.repo_root,
|
|
)
|
|
self._write_evidence("P2-pytest.txt", result.output + "\n" + agent_result.output)
|
|
if result.return_code == 0 and agent_result.return_code == 0:
|
|
self._record("P2", Status.PASS, "all Grok-related unit tests pass")
|
|
else:
|
|
self._record("P2", Status.FAIL, "unit tests failed — live results would be meaningless (see evidence/P2-pytest.txt)")
|
|
raise AbortError("fix the unit tests before live-testing")
|
|
|
|
def check_p4_context_loads(self) -> None:
|
|
self._section("P4 — grok context loads from local source")
|
|
code = (
|
|
"from serena.config.context_mode import SerenaAgentContext\n"
|
|
"ctx = SerenaAgentContext.from_name('grok')\n"
|
|
"print(ctx.name); print(ctx.single_project); print(','.join(sorted(ctx.excluded_tools)))"
|
|
)
|
|
result = run_command([str(self.config.python_bin), "-c", code])
|
|
self._write_evidence("P4-context.txt", result.output)
|
|
lines = result.stdout.strip().splitlines()
|
|
expected = [
|
|
"grok",
|
|
"True",
|
|
",".join(sorted(CONTEXT_EXCLUDED_TOOLS)),
|
|
]
|
|
if result.return_code == 0 and lines == expected:
|
|
self._record("P4", Status.PASS, "name, single_project and tool exclusions as expected")
|
|
else:
|
|
self._record("P4", Status.FAIL, "context mismatch (see evidence/P4-context.txt)")
|
|
|
|
# ------------------------------------------------------------------ phase H: hook protocol
|
|
|
|
def _check_remind_burst(
|
|
self, check_id: str, title: str, deny_on_call: int, reason_fragment: str, calls: list[tuple[str, dict]]
|
|
) -> None:
|
|
"""
|
|
Feeds a burst of PreToolUse payloads to the remind hook and asserts that only the
|
|
``deny_on_call``-th call produces a native Grok deny containing ``reason_fragment``.
|
|
"""
|
|
self._section(f"{check_id} — {title}")
|
|
evidence_lines = []
|
|
problem = None
|
|
for index, (tool_name, tool_input) in enumerate(calls, start=1):
|
|
result = self._run_hook("remind", self._pre_tool_use_payload(f"live-{check_id}", tool_name, tool_input))
|
|
evidence_lines.append(f"--- call {index} (tool={tool_name}) rc={result.return_code} ---\n{result.stdout}")
|
|
output = result.stdout.strip()
|
|
|
|
# every call must exit cleanly; only the final call may (and must) produce a deny
|
|
if result.return_code != 0:
|
|
problem = f"call {index} exited {result.return_code}"
|
|
elif index < deny_on_call and output:
|
|
problem = f"premature output on call {index}"
|
|
elif index == deny_on_call:
|
|
problem = self._validate_native_deny(output, reason_fragment)
|
|
if problem:
|
|
break
|
|
self._write_evidence(f"{check_id}.txt", "\n".join(evidence_lines))
|
|
if problem is None:
|
|
self._record(check_id, Status.PASS, f"deny on call {deny_on_call} with expected native shape")
|
|
else:
|
|
self._record(check_id, Status.FAIL, f"{problem} (see evidence/{check_id}.txt)")
|
|
|
|
@staticmethod
|
|
def _validate_native_deny(output: str, reason_fragment: str) -> Optional[str]:
|
|
""":return: a problem description if ``output`` is not a native Grok deny containing ``reason_fragment``, else None"""
|
|
try:
|
|
decision = json.loads(output)
|
|
except json.JSONDecodeError:
|
|
return f"deny output is not valid JSON: {output!r}"
|
|
if decision.get("decision") != "deny":
|
|
return f"expected a deny, got {output!r}"
|
|
if reason_fragment not in decision.get("reason", ""):
|
|
return f"deny reason lacks {reason_fragment!r}"
|
|
if "hookSpecificOutput" in decision:
|
|
return "Claude-shaped output leaked into the Grok format"
|
|
return None
|
|
|
|
def check_h_hook_protocol(self) -> None:
|
|
grep_call = ("grep", {"pattern": "foo", "path": "."})
|
|
read_call = ("read_file", {"target_file": "src/foo.py"})
|
|
shell_grep_call = ("run_terminal_command", {"command": "rg -n foo src/bar.py"})
|
|
|
|
self._check_remind_burst("H1", "grep burst → native deny on 3rd call", 3, "Too many consecutive grep calls", [grep_call] * 3)
|
|
|
|
self._section("H2 — deny rate-limit window (same session, immediately after H1)")
|
|
result = self._run_hook("remind", self._pre_tool_use_payload("live-H1", "grep", {"pattern": "bar", "path": "."}))
|
|
self._write_evidence("H2.txt", f"rc={result.return_code}\n{result.stdout}")
|
|
if result.return_code == 0 and not result.stdout.strip():
|
|
self._record("H2", Status.PASS, "hook is a no-op within the 120s post-deny window")
|
|
else:
|
|
self._record("H2", Status.FAIL, f"expected silence, got rc={result.return_code} out={result.stdout.strip()!r}")
|
|
|
|
self._check_remind_burst(
|
|
"H3", "read_file burst (target_file) → deny on 3rd call", 3, "Too many consecutive read calls", [read_call] * 3
|
|
)
|
|
self._check_remind_burst(
|
|
"H4",
|
|
"run_terminal_command + rg classified as grep → deny on 3rd call",
|
|
3,
|
|
"Too many consecutive grep calls",
|
|
[shell_grep_call] * 3,
|
|
)
|
|
self._check_remind_burst(
|
|
"H5",
|
|
"mixed grep/read burst → combined deny on 4th call",
|
|
4,
|
|
"mixed grep and read",
|
|
[grep_call, read_call, grep_call, read_call],
|
|
)
|
|
|
|
self._section("H6 — neutral tools (edit/list) never trigger")
|
|
neutral_calls = [
|
|
("search_replace", {"target_file": "src/a.py", "instructions": "x"}),
|
|
("list_dir", {"target_directory": "."}),
|
|
("search_replace", {"target_file": "src/b.py"}),
|
|
]
|
|
neutral_outputs = [
|
|
self._run_hook("remind", self._pre_tool_use_payload("live-H6", name, tool_input)) for name, tool_input in neutral_calls
|
|
]
|
|
self._write_evidence("H6.txt", "\n".join(f"rc={r.return_code} out={r.stdout!r}" for r in neutral_outputs))
|
|
if all(r.return_code == 0 and not r.stdout.strip() for r in neutral_outputs):
|
|
self._record("H6", Status.PASS, "search_replace/list_dir stay silent")
|
|
else:
|
|
self._record("H6", Status.FAIL, "a neutral tool produced output or an error (see evidence/H6.txt)")
|
|
|
|
self._section("H7 — auto-approve emits native allow")
|
|
result = self._run_hook(
|
|
"auto-approve", self._pre_tool_use_payload("live-H7", "serena__find_symbol", {}, permission_mode="acceptEdits")
|
|
)
|
|
self._write_evidence("H7.txt", f"rc={result.return_code}\n{result.stdout}")
|
|
if result.return_code == 0 and result.stdout.strip() == '{"decision": "allow"}':
|
|
self._record("H7", Status.PASS, 'exact native allow: {"decision": "allow"}')
|
|
else:
|
|
self._record("H7", Status.FAIL, f"expected exact native allow, got {result.stdout.strip()!r}")
|
|
|
|
self._section("H8 — activate accepts camelCase sessionId (informational)")
|
|
result = self._run_hook("activate", {"sessionId": "live-H8"})
|
|
self._write_evidence("H8.txt", f"rc={result.return_code}\n{result.stdout}")
|
|
if result.return_code == 0 and '"hookEventName": "SessionStart"' in result.stdout:
|
|
self._record(
|
|
"H8", Status.INFO, "runs clean; emits the client-agnostic SessionStart shape (Grok setup omits this hook by design)"
|
|
)
|
|
elif result.return_code == 0:
|
|
self._record("H8", Status.WARN, "ran clean but produced unexpected output (see evidence/H8.txt)")
|
|
else:
|
|
self._record("H8", Status.FAIL, f"activate crashed (rc={result.return_code})")
|
|
|
|
self._section("H9 — cleanup removes exactly its session's state")
|
|
hook_data_dir = self.config.hook_home / "hook_data"
|
|
if not hook_data_dir.is_dir():
|
|
self._record("H9", Status.FAIL, "no hook state was persisted by the preceding checks — nothing to clean up")
|
|
return
|
|
before = sorted(p.name for p in hook_data_dir.iterdir())
|
|
result = self._run_hook("cleanup", {"sessionId": "live-H1"})
|
|
after = sorted(p.name for p in hook_data_dir.iterdir())
|
|
self._write_evidence("H9.txt", f"before={before}\nafter={after}\nrc={result.return_code}")
|
|
if result.return_code == 0 and not result.stdout.strip() and "live-H1" not in after and "live-H3" in after:
|
|
self._record("H9", Status.PASS, "live-H1 state removed, other sessions untouched")
|
|
else:
|
|
self._record("H9", Status.FAIL, "cleanup did not behave as expected (see evidence/H9.txt)")
|
|
|
|
# ------------------------------------------------------------------ phase S: client setup
|
|
|
|
def check_s_client_setup(self) -> None:
|
|
self._section("S1 — serena setup grok")
|
|
expected_add_command = "grok mcp add --scope user serena -- serena start-mcp-server --context=grok --project-from-cwd"
|
|
result = run_command([str(self.config.serena_bin), "setup", "grok"], timeout=120, cwd=self.config.repo_root)
|
|
self._write_evidence("S1.txt", result.output)
|
|
expected_fragments = (
|
|
expected_add_command,
|
|
"IMPORTANT: We additionally recommend to set up hooks for Grok",
|
|
"030_clients.html#grok",
|
|
"Serena has been successfully set up for grok.",
|
|
)
|
|
if result.return_code == 0 and all(fragment in result.stdout for fragment in expected_fragments):
|
|
self._registered_server_names.append("serena")
|
|
self._record("S1", Status.PASS, "registered against the real grok CLI with all expected messaging")
|
|
else:
|
|
self._record("S1", Status.FAIL, f"rc={result.return_code}, output diverges (see evidence/S1.txt)")
|
|
|
|
self._section("S2 — registration content is exact")
|
|
section = self._read_grok_mcp_server_section("serena")
|
|
list_result = self._grok("mcp", "list")
|
|
self._write_evidence("S2.txt", f"mcp list:\n{list_result.output}\n\n[mcp_servers.serena] = {section!r}")
|
|
registered_tokens = " ".join([str(section.get("command", "")), *section.get("args", [])])
|
|
required_tokens = ("serena", "start-mcp-server", "--context=grok", "--project-from-cwd")
|
|
if "serena" in list_result.output and all(token in registered_tokens for token in required_tokens):
|
|
self._record("S2", Status.PASS, "config registers command 'serena' with the context and project flags")
|
|
else:
|
|
self._record("S2", Status.FAIL, f"registered entry incomplete: {section!r}")
|
|
|
|
self._section("S3 — grok inspect discovers the server")
|
|
inspect_result = self._grok("inspect", "--json", timeout=60)
|
|
self._write_evidence("S3-inspect.json", inspect_result.output)
|
|
server_names = self._extract_inspect_mcp_server_names(inspect_result.stdout)
|
|
if "serena" in server_names:
|
|
self._record("S3", Status.PASS, "inspect --json lists serena under mcpServers")
|
|
else:
|
|
self._record("S3", Status.WARN, f"serena not among inspect's mcpServers {server_names} — review evidence/S3-inspect.json")
|
|
|
|
self._section("S4 — idempotent re-run")
|
|
rerun = run_command([str(self.config.serena_bin), "setup", "grok"], timeout=120, cwd=self.config.repo_root)
|
|
self._write_evidence("S4.txt", rerun.output)
|
|
if rerun.return_code == 0 and self._read_grok_mcp_server_section("serena"):
|
|
self._record("S4", Status.PASS, "re-run succeeds and the entry remains registered exactly once")
|
|
else:
|
|
self._record("S4", Status.FAIL, f"re-run rc={rerun.return_code}")
|
|
|
|
self._section("S5 — negative path: grok missing from PATH → clean refusal")
|
|
minimal_path = "C:\\Windows\\System32" if sys.platform == "win32" else "/usr/bin:/bin"
|
|
result = run_command([str(self.config.serena_bin), "setup", "grok"], env_overlay={"PATH": minimal_path})
|
|
self._write_evidence("S5.txt", f"rc={result.return_code}\n{result.output}")
|
|
if result.return_code == 1 and "Cannot apply setup for client 'grok'" in result.stdout:
|
|
self._record("S5", Status.PASS, "is_applicable()==False path exits 1 with the documented message")
|
|
else:
|
|
self._record("S5", Status.FAIL, f"rc={result.return_code} (expected 1) or message mismatch (see evidence/S5.txt)")
|
|
|
|
@staticmethod
|
|
def _extract_inspect_mcp_server_names(inspect_json: str) -> list[str]:
|
|
""":return: the MCP server names listed by ``grok inspect --json``, or an empty list if the output cannot be parsed"""
|
|
try:
|
|
data = json.loads(inspect_json)
|
|
return [server.get("name", "") for server in data.get("mcpServers", [])]
|
|
except (json.JSONDecodeError, AttributeError, TypeError):
|
|
return []
|
|
|
|
# ------------------------------------------------------------------ phase M: MCP server
|
|
|
|
def _server_argv(self, with_project: bool, context: str = "grok") -> list[str]:
|
|
argv = [str(self.config.serena_bin), "start-mcp-server", "--context", context, "--enable-web-dashboard", "false"]
|
|
if with_project:
|
|
argv.insert(2, "--project-from-cwd")
|
|
return argv
|
|
|
|
def _check_mcp_handshake(
|
|
self, check_id: str, title: str, cwd: Path, with_project: bool, required: tuple[str, ...], forbidden: tuple[str, ...]
|
|
) -> None:
|
|
"""
|
|
Starts the MCP server over stdio, performs the initialize handshake, lists tools and
|
|
asserts the presence/absence of the given tool names.
|
|
"""
|
|
self._section(f"{check_id} — {title}")
|
|
with self._open_evidence(f"{check_id}-server.log") as stderr_log:
|
|
probe = McpStdioProbe(self._server_argv(with_project), cwd=cwd, stderr_log=stderr_log)
|
|
try:
|
|
init_params = {
|
|
"protocolVersion": "2024-11-05",
|
|
"capabilities": {},
|
|
"clientInfo": {"name": "serena-live-test", "version": "0.0.0"},
|
|
}
|
|
initialization = probe.request(1, "initialize", init_params, timeout=240)
|
|
probe.notify("notifications/initialized")
|
|
tools_response = probe.request(2, "tools/list", {}, timeout=240)
|
|
except AbortError as e:
|
|
self._record(check_id, Status.FAIL, f"{e} (see evidence/{check_id}-server.log)")
|
|
return
|
|
finally:
|
|
probe.close()
|
|
|
|
# evaluate the exposed tool set against the context's contract
|
|
tools = sorted(tool["name"] for tool in tools_response["result"]["tools"])
|
|
server_info = initialization["result"].get("serverInfo", {})
|
|
self._write_evidence(f"{check_id}.txt", f"server: {server_info}\ntool count: {len(tools)}\ntools: {','.join(tools)}")
|
|
leaked = sorted(set(forbidden) & set(tools))
|
|
missing = sorted(set(required) - set(tools))
|
|
if not leaked and not missing:
|
|
self._record(check_id, Status.PASS, f"handshake ok, tool set as expected ({len(tools)} tools)")
|
|
else:
|
|
self._record(check_id, Status.FAIL, f"forbidden tools exposed: {leaked or 'none'}; required tools missing: {missing or 'none'}")
|
|
|
|
def check_m_mcp_server(self) -> None:
|
|
no_project_dir = self.config.work_dir / "noproject"
|
|
no_project_dir.mkdir(exist_ok=True)
|
|
self._check_mcp_handshake(
|
|
"M1",
|
|
"MCP handshake, no project (context exclusions over the wire)",
|
|
no_project_dir,
|
|
with_project=False,
|
|
required=(),
|
|
forbidden=CONTEXT_EXCLUDED_TOOLS,
|
|
)
|
|
self._check_mcp_handshake(
|
|
"M2",
|
|
"MCP handshake with --project-from-cwd (single-project mode)",
|
|
self.config.repo_root,
|
|
with_project=True,
|
|
required=REQUIRED_SYMBOLIC_TOOLS,
|
|
forbidden=(*CONTEXT_EXCLUDED_TOOLS, "activate_project"),
|
|
)
|
|
|
|
self._section("M3 — grok mcp doctor spawns the real server (absolute serena path)")
|
|
add_result = self._grok(
|
|
"mcp",
|
|
"add",
|
|
"--scope",
|
|
"user",
|
|
LIVE_MCP_SERVER_NAME,
|
|
"--",
|
|
str(self.config.serena_bin),
|
|
"start-mcp-server",
|
|
"--context=grok",
|
|
"--project-from-cwd",
|
|
"--enable-web-dashboard",
|
|
"false",
|
|
)
|
|
if add_result.return_code != 0:
|
|
self._write_evidence("M3.txt", add_result.output)
|
|
self._record("M3", Status.FAIL, f"could not register the {LIVE_MCP_SERVER_NAME} entry (see evidence/M3.txt)")
|
|
else:
|
|
self._registered_server_names.append(LIVE_MCP_SERVER_NAME)
|
|
self._check_m3_doctor()
|
|
|
|
self._section("M4 — readiness of the 'serena' command on PATH (informational)")
|
|
self._check_m4_path_serena()
|
|
|
|
self._check_m5_structured_output()
|
|
|
|
# the production and test entries are no longer needed past this point
|
|
self._cleanup_grok_state()
|
|
|
|
def _doctor_looks_healthy(self, result: CommandResult) -> bool:
|
|
return result.return_code == 0 and not any(
|
|
marker in result.output.lower() for marker in ("timeout", "timed out", "failed", "error")
|
|
)
|
|
|
|
def _check_m3_doctor(self) -> None:
|
|
evidence = []
|
|
|
|
# first attempt, then a plain retry (grok may cache a slow first spawn)
|
|
doctor = self._grok("mcp", "doctor", LIVE_MCP_SERVER_NAME, "--json", timeout=180)
|
|
evidence.append(doctor.output)
|
|
if not self._doctor_looks_healthy(doctor):
|
|
doctor = self._grok("mcp", "doctor", LIVE_MCP_SERVER_NAME, "--json", timeout=180)
|
|
evidence.append("--- retry ---\n" + doctor.output)
|
|
|
|
# if the spawn seems to time out, retry with a raised startup timeout (above grok's 30s default);
|
|
# 'grok mcp add' has no flag for it, so it must be inserted into the config directly
|
|
if not self._doctor_looks_healthy(doctor) and self._insert_startup_timeout_sec(60):
|
|
doctor = self._grok("mcp", "doctor", LIVE_MCP_SERVER_NAME, "--json", timeout=180)
|
|
evidence.append("--- with startup_timeout_sec=60 ---\n" + doctor.output)
|
|
if self._doctor_looks_healthy(doctor):
|
|
self._finding(
|
|
"grok's default MCP startup timeout (30s) was too low for Serena's cold start in this environment; "
|
|
"raising startup_timeout_sec (e.g. 60) in the TOML fixed it — worth noting for slow environments"
|
|
)
|
|
|
|
self._write_evidence("M3-doctor.txt", "\n".join(evidence))
|
|
if self._doctor_looks_healthy(doctor):
|
|
self._record("M3", Status.PASS, "doctor reports the server connectable (heuristic — skim evidence/M3-doctor.txt)")
|
|
else:
|
|
self._record("M3", Status.FAIL, f"doctor rc={doctor.return_code} (see evidence/M3-doctor.txt)")
|
|
|
|
def _check_m4_path_serena(self) -> None:
|
|
"""Checks whether the ``serena`` resolved via PATH (what the production registration will spawn) supports the grok context."""
|
|
path_serena = shutil.which("serena")
|
|
if path_serena is None:
|
|
self._record("M4", Status.INFO, "no 'serena' on PATH — the production registration requires one (e.g. via uv tool install)")
|
|
return
|
|
if Path(path_serena).resolve() == self.config.serena_bin.resolve():
|
|
self._record(
|
|
"M4", Status.INFO, "PATH resolves 'serena' to the binary under test — production registration will behave like M1-M3"
|
|
)
|
|
return
|
|
|
|
# probe the foreign installation briefly: surviving the timeout means the context loaded
|
|
probe = run_command([path_serena, "start-mcp-server", "--context", "grok", "--enable-web-dashboard", "false"], timeout=15)
|
|
self._write_evidence(
|
|
"M4.txt", f"PATH serena: {path_serena}\nrc={probe.return_code} timed_out={probe.timed_out}\n{probe.output[-3000:]}"
|
|
)
|
|
if probe.timed_out:
|
|
self._record(
|
|
"M4", Status.INFO, f"'serena' on PATH ({path_serena}) supports the grok context — production registration is functional"
|
|
)
|
|
else:
|
|
self._record(
|
|
"M4", Status.INFO, f"'serena' on PATH ({path_serena}) cannot start with the grok context — likely a pre-Grok release"
|
|
)
|
|
self._finding(
|
|
"the production registration spawns the bare 'serena' command; it only works once the installation on PATH "
|
|
"includes Grok support (see evidence/M4.txt for the observed failure)"
|
|
)
|
|
|
|
def _probe_tool_call(self, check_id: str, context: str) -> Optional[tuple[Optional[dict], dict]]:
|
|
"""
|
|
Starts the MCP server with the given context (project mode), calls the read-only probe tool
|
|
and returns its advertised output schema together with the raw ``tools/call`` result.
|
|
|
|
:param check_id: the check id (used for evidence file names and FAIL records)
|
|
:param context: the Serena context to start the server with
|
|
:return: ``(output_schema, call_result)``, or None if the probe failed (a FAIL is recorded)
|
|
"""
|
|
with self._open_evidence(f"{check_id}-{context}-server.log") as stderr_log:
|
|
probe = McpStdioProbe(self._server_argv(with_project=True, context=context), cwd=self.config.repo_root, stderr_log=stderr_log)
|
|
try:
|
|
init_params = {
|
|
"protocolVersion": "2024-11-05",
|
|
"capabilities": {},
|
|
"clientInfo": {"name": "serena-live-test", "version": "0.0.0"},
|
|
}
|
|
probe.request(1, "initialize", init_params, timeout=240)
|
|
probe.notify("notifications/initialized")
|
|
tools_response = probe.request(2, "tools/list", {}, timeout=240)
|
|
call_response = probe.request(3, "tools/call", {"name": STRUCTURED_OUTPUT_PROBE_TOOL, "arguments": {}}, timeout=240)
|
|
except AbortError as e:
|
|
self._record(check_id, Status.FAIL, f"{context}: {e} (see evidence/{check_id}-{context}-server.log)")
|
|
return None
|
|
finally:
|
|
probe.close()
|
|
|
|
entries = [tool for tool in tools_response["result"]["tools"] if tool["name"] == STRUCTURED_OUTPUT_PROBE_TOOL]
|
|
if not entries:
|
|
self._record(check_id, Status.FAIL, f"{context}: probe tool '{STRUCTURED_OUTPUT_PROBE_TOOL}' not exposed")
|
|
return None
|
|
return entries[0].get("outputSchema"), call_response.get("result", {})
|
|
|
|
def _check_m5_structured_output(self) -> None:
|
|
"""
|
|
Verifies the structured-tool-output wire shape of the grok context (auto default) against the
|
|
claude-code context (explicit ``false`` workaround): for a string-returning tool, the grok
|
|
context must advertise an output schema and return ``structuredContent`` while claude-code
|
|
must serve plain text only.
|
|
|
|
This pins what Serena *serves* under each context; whether Grok Build unpacks
|
|
``structuredContent`` client-side can only be observed in a real agent session, which is
|
|
out of scope for a zero-inference test.
|
|
"""
|
|
self._section("M5 — structured tool output over the wire (grok auto default vs claude-code workaround)")
|
|
grok_probe = self._probe_tool_call("M5", "grok")
|
|
if grok_probe is None:
|
|
return
|
|
claude_probe = self._probe_tool_call("M5", "claude-code")
|
|
if claude_probe is None:
|
|
return
|
|
grok_schema, grok_result = grok_probe
|
|
claude_schema, claude_result = claude_probe
|
|
|
|
problems = []
|
|
grok_structured = grok_result.get("structuredContent")
|
|
if grok_result.get("isError"):
|
|
problems.append("grok: probe tool call returned isError")
|
|
if not isinstance(grok_schema, dict):
|
|
problems.append("grok: no outputSchema advertised for the string-returning probe tool")
|
|
if not (isinstance(grok_structured, dict) and isinstance(grok_structured.get("result"), str)):
|
|
problems.append("grok: structuredContent missing or not of shape {'result': str}")
|
|
if claude_result.get("isError"):
|
|
problems.append("claude-code: probe tool call returned isError")
|
|
if claude_schema is not None:
|
|
problems.append("claude-code: unexpectedly advertises an outputSchema despite structured_tool_output=false")
|
|
if claude_result.get("structuredContent") is not None:
|
|
problems.append("claude-code: unexpectedly returns structuredContent despite structured_tool_output=false")
|
|
|
|
self._write_evidence(
|
|
"M5.txt",
|
|
f"probe tool: {STRUCTURED_OUTPUT_PROBE_TOOL}\n"
|
|
f"grok outputSchema: {json.dumps(grok_schema)}\n"
|
|
f"grok structuredContent keys: {sorted(grok_structured) if isinstance(grok_structured, dict) else grok_structured!r}\n"
|
|
f"claude-code outputSchema: {json.dumps(claude_schema)}\n"
|
|
f"claude-code structuredContent: {claude_result.get('structuredContent')!r}\n",
|
|
)
|
|
if problems:
|
|
self._record("M5", Status.FAIL, "; ".join(problems))
|
|
else:
|
|
self._record(
|
|
"M5",
|
|
Status.PASS,
|
|
"grok serves outputSchema + structuredContent({'result': str}); claude-code serves plain text only "
|
|
"(auto-default divergence verified on the wire)",
|
|
)
|
|
|
|
# ------------------------------------------------------------------ phase D: hook discovery
|
|
|
|
def check_d_hook_discovery(self) -> None:
|
|
hooks_json_text = json.dumps(HOOKS_JSON, indent=2)
|
|
|
|
self._section("D1 — project-scoped .grok/hooks discovery")
|
|
hook_project = self.config.work_dir / "hookproj"
|
|
hooks_dir = hook_project / ".grok" / "hooks"
|
|
hooks_dir.mkdir(parents=True)
|
|
run_command(["git", "init", "-q", "."], cwd=hook_project)
|
|
(hooks_dir / "serena-hooks.json").write_text(hooks_json_text, encoding="utf-8")
|
|
inspect_result = run_command([self.config.grok_bin, "inspect", "--json"], timeout=60, cwd=hook_project)
|
|
self._write_evidence("D1-inspect.json", inspect_result.output)
|
|
if "serena-hooks" in inspect_result.output:
|
|
self._record("D1", Status.PASS, "grok inspect discovers the docs' hooks JSON in the project's .grok/hooks/")
|
|
else:
|
|
self._record(
|
|
"D1", Status.WARN, "project hooks not visible in inspect output — possibly gated on hook trust (evidence/D1-inspect.json)"
|
|
)
|
|
self._finding(
|
|
"the docs' project-scoped hooks JSON was not surfaced by 'grok inspect --json' in an untrusted project; "
|
|
"Grok requires /hooks-trust before project hooks load (expected; documented in the Grok hooks docs section)"
|
|
)
|
|
|
|
self._section("D2 — global hooks directory next to the Grok config (informational)")
|
|
global_hooks_dir = self.config.grok_config_path.parent / "hooks"
|
|
global_hooks_file = global_hooks_dir / "serena-hooks.json"
|
|
if global_hooks_file.exists():
|
|
self._record("D2", Status.SKIP, f"{global_hooks_file} already exists — not probing to avoid touching user state")
|
|
return
|
|
global_hooks_dir.mkdir(exist_ok=True)
|
|
global_hooks_file.write_text(hooks_json_text, encoding="utf-8")
|
|
self._created_global_hooks_file = global_hooks_file
|
|
inspect_result = run_command([self.config.grok_bin, "inspect", "--json"], timeout=60, cwd=self.config.work_dir / "noproject")
|
|
self._write_evidence("D2-inspect.json", inspect_result.output)
|
|
if "serena-hooks" in inspect_result.output:
|
|
self._record("D2", Status.INFO, "global hooks directory IS discovered — the docs' global recommendation works")
|
|
else:
|
|
self._record("D2", Status.INFO, "global hooks directory NOT discovered — the docs' 'globally' wording may need correction")
|
|
self._finding("the docs claim a global hooks file next to the Grok config works; 'grok inspect' did not surface it")
|
|
self._cleanup_grok_state()
|
|
|
|
# ------------------------------------------------------------------ phase C: cleanup
|
|
|
|
def check_c1_cleanup(self) -> None:
|
|
self._section("C1 — cleanup and baseline verification")
|
|
self._cleanup_grok_state()
|
|
self._cleanup_done = True
|
|
if self._baseline_mcp_list == "":
|
|
self._record("C1", Status.SKIP, "no baseline was captured (aborted before P3)")
|
|
return
|
|
|
|
backup_exists = self.config.config_backup_path.exists()
|
|
final_list = self._grok("mcp", "list").output
|
|
self._write_evidence("C1-final-mcp-list.txt", final_list)
|
|
print(final_list.strip())
|
|
if final_list == self._baseline_mcp_list:
|
|
if not backup_exists:
|
|
self._record("C1", Status.PASS, "mcp list matches the baseline; config was not modified (hooks-only, no backup)")
|
|
return
|
|
backup_matches = (
|
|
self.config.grok_config_path.exists()
|
|
and self.config.grok_config_path.read_bytes() == self.config.config_backup_path.read_bytes()
|
|
)
|
|
note = "byte-identical to the backup" if backup_matches else "differs from the backup only in formatting (mcp list matches)"
|
|
self._record("C1", Status.PASS, f"mcp list matches the baseline; config {note}")
|
|
self._discard_config_backup()
|
|
return
|
|
|
|
# the removals did not return to baseline: restore the backup outright
|
|
if not backup_exists:
|
|
self._record("C1", Status.FAIL, "state diverged but no config backup exists to restore — MANUAL ATTENTION")
|
|
return
|
|
shutil.copy2(self.config.config_backup_path, self.config.grok_config_path)
|
|
restored_list = self._grok("mcp", "list").output
|
|
if restored_list == self._baseline_mcp_list:
|
|
self._record("C1", Status.PASS, "state diverged after removals; restored the config backup — baseline verified")
|
|
self._discard_config_backup()
|
|
else:
|
|
self._record(
|
|
"C1", Status.FAIL, f"could not restore the baseline — MANUAL ATTENTION, backup at {self.config.config_backup_path}"
|
|
)
|
|
|
|
def _discard_config_backup(self) -> None:
|
|
"""Remove the config backup once the baseline is confirmed intact, so no credential copy persists."""
|
|
try:
|
|
self.config.config_backup_path.unlink(missing_ok=True)
|
|
except OSError:
|
|
pass
|
|
|
|
# ------------------------------------------------------------------ orchestration
|
|
|
|
def run(self) -> int:
|
|
"""
|
|
Runs all phases, guaranteeing Grok-side cleanup even on abort.
|
|
|
|
:return: the process exit code (0 iff no check failed)
|
|
"""
|
|
aborted: Optional[str] = None
|
|
try:
|
|
self.check_p1_environment()
|
|
self.check_p3_baseline()
|
|
self.check_p2_unit_smoke()
|
|
self.check_p4_context_loads()
|
|
self.check_h_hook_protocol()
|
|
if self.config.hooks_only:
|
|
for check_id in ("S1", "S2", "S3", "S4", "S5", "M1", "M2", "M3", "M4", "M5", "D1", "D2"):
|
|
self._record(check_id, Status.SKIP, "--hooks-only")
|
|
else:
|
|
self.check_s_client_setup()
|
|
self.check_m_mcp_server()
|
|
self.check_d_hook_discovery()
|
|
except AbortError as e:
|
|
aborted = str(e)
|
|
print(f"\nABORTED: {e}")
|
|
finally:
|
|
self.check_c1_cleanup()
|
|
|
|
report_path = self._write_report(aborted)
|
|
print(f"\nreport written to {report_path}")
|
|
failed = sum(1 for result in self.results if result.status == Status.FAIL)
|
|
return 1 if failed or aborted else 0
|
|
|
|
def _write_report(self, aborted: Optional[str]) -> Path:
|
|
counts = {status: sum(1 for r in self.results if r.status == status) for status in Status}
|
|
lines = [
|
|
"# Grok Live Test Report",
|
|
"",
|
|
f"- Date: {datetime.now().isoformat(timespec='seconds')}",
|
|
f"- Environment: {self._environment_summary or 'n/a'}",
|
|
f"- Mode: {'hooks-only' if self.config.hooks_only else 'full'}" + (", unit tests skipped" if self.config.skip_unit else ""),
|
|
f"- Totals: {counts[Status.PASS]} pass / {counts[Status.FAIL]} fail / {counts[Status.WARN]} warn"
|
|
f" / {counts[Status.INFO]} info / {counts[Status.SKIP]} skipped",
|
|
"- Cost: zero inference (no Grok session was started)",
|
|
]
|
|
if aborted:
|
|
lines.append(f"- **ABORTED**: {aborted}")
|
|
lines += ["", "| ID | Status | Observation |", "|----|--------|-------------|"]
|
|
lines += [f"| {r.check_id} | {r.status.value} | {r.note} |" for r in self.results]
|
|
lines += ["", "## Findings", ""]
|
|
lines += [f"{i}. {finding}" for i, finding in enumerate(self.findings, start=1)] or ["None."]
|
|
lines += ["", f"Evidence: {self.config.evidence_dir}/", ""]
|
|
report_path = self.config.work_dir / "report.md"
|
|
report_path.write_text("\n".join(lines), encoding="utf-8")
|
|
os.chmod(report_path, 0o600)
|
|
return report_path
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(
|
|
description="Live (zero-inference) smoke test of Serena's Grok Build integration against a real grok CLI.",
|
|
epilog="Backs up the Grok config before any change and restores the baseline afterwards; "
|
|
"refuses to run if a 'serena' MCP entry is already registered in Grok.",
|
|
)
|
|
parser.add_argument("--hooks-only", action="store_true", help="run only the pure-local checks; never touches the Grok configuration")
|
|
parser.add_argument("--skip-unit", action="store_true", help="skip the pytest smoke run (phase P2)")
|
|
parser.add_argument("--work-dir", help="directory for evidence and reports (default: a fresh private mkdtemp under the system tmp)")
|
|
parser.add_argument("--repo-root", help="Serena repository root (default: derived from this script's location)")
|
|
parser.add_argument("--serena-bin", help="serena executable under test (default: <repo>/.venv)")
|
|
parser.add_argument("--serena-hooks-bin", help="serena-hooks executable under test (default: <repo>/.venv)")
|
|
parser.add_argument("--grok-bin", help="grok executable (default: resolved from PATH)")
|
|
parser.add_argument(
|
|
"--grok-config",
|
|
help="Grok user config file, for nonstandard Grok homes (default: ~/.grok/config.toml). NOT an isolation "
|
|
"mechanism: 'grok mcp' has no alternate-config option, so this must be the config the CLI actually uses — "
|
|
"verified against 'grok mcp list' at startup, mismatch aborts before any mutation",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
try:
|
|
config = LiveTestConfig.from_args(args)
|
|
except AbortError as e:
|
|
print(f"ABORTED: {e}")
|
|
return 1
|
|
print(f"work directory: {config.work_dir}")
|
|
return GrokLiveTest(config).run()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|