mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
fix(grok): address adversarial-review findings across the grok-CLI conversion
A 7-dimension adversarial review (find -> independently refute) surfaced 14 real issues; fixed each: Runtime bugs - GrokCliSession.send drained stdout fully BEFORE stderr — a >64KB stderr burst would deadlock the turn forever (spinner never clears). Drain stderr concurrently, and add a per-turn watchdog (ROBOCO_GROK_TURN_TIMEOUT_SECONDS, default 600s) that kills a wedged process and emits error+turn_end. - Crash-restarted grok agents launched `grok -p ""` (empty prompt) — Claude gets a scan-for-work fallback. Default the prompt in _spawn_container so every dedicated provider gets it too. - _grok_usage_json read /data/grok-usage unconditionally while its writers branch compose-vs-local, so a local-mode agent finalized at $0 and the cost-cap was inert. Single-source the path in a new _grok_usage_dir helper (read == write). - GrokCliSession secretary role fell through to "unknown" (get_agent_role returns a truthy sentinel, never None), defeating the ROBOCO_AGENT_ROLE fallback. Parity / hardening - --deny set was missing `git tag -d` / `git reflog delete` that the Claude bash-guard blocks — added them (the "same set" claim is now true). - Interactive mains now install the bash-guard hook too (defense-in-depth). - Compose: collapse the GROK_AUTH_DIR / ROBOCO_HOST_GROK_DIR auth-mount pair into one canonical var so a partial override can't silently break agent auth. Docs / comments - Panel routing card + architecture security doc no longer say Grok runs on the deleted opencode runtime; orchestrator comments point at the renamed entrypoint. Tests - Cover the interactive _render_grok_config MCP wiring (ModuleNotFound guard + secretary HMAC env), the cost-cap kill-failure + interactive relay-close paths, the local-mode usage read, the role fallback, the turn timeout, and the new git denies. (#13 — a separate grok "Write" tool — investigated: grok's only built-in file-mutation tool is search_replace, already removed; no gap.) Gate green: ruff, mypy, xenon, tests.
This commit is contained in:
+3
-4
@@ -91,11 +91,10 @@ ROBOCO_DEFAULT_EMBEDDING_MODEL=qwen3-embedding:0.6b
|
||||
# the CLI authenticates from a mounted ~/.grok/auth.json — run `grok login` once
|
||||
# on the host (auth.json auto-refreshes). Every var below is optional.
|
||||
|
||||
# Host dir holding the SuperGrok auth. The orchestrator mounts <dir>/auth.json
|
||||
# read-only into each Grok agent's ~/.grok. GROK_AUTH_DIR is the host source the
|
||||
# compose mounts into the orchestrator; keep both equal to the host's ~/.grok.
|
||||
# Host dir holding the SuperGrok auth (one canonical var). The compose mounts it
|
||||
# into the orchestrator at the same path, and the orchestrator hands that path to
|
||||
# each Grok agent's auth.json bind — so it must be the real host ~/.grok.
|
||||
# ROBOCO_HOST_GROK_DIR=/home/youruser/.grok
|
||||
# GROK_AUTH_DIR=/home/youruser/.grok
|
||||
|
||||
# Image the orchestrator spawns for Grok agents, and the CLI model id.
|
||||
# ROBOCO_GROK_AGENT_IMAGE=roboco-agent-grok:latest
|
||||
|
||||
@@ -243,7 +243,7 @@ services:
|
||||
# SuperGrok auth — mount host ~/.grok at the SAME host path the orchestrator
|
||||
# hands each Grok agent's `-v`, so its auth.json exists() check passes here
|
||||
# AND the agent bind resolves on the host. `grok login` on the host. RO.
|
||||
- ${GROK_AUTH_DIR:-${HOME}/.grok}:${ROBOCO_HOST_GROK_DIR:-${HOME}/.grok}:ro
|
||||
- ${ROBOCO_HOST_GROK_DIR:-${HOME}/.grok}:${ROBOCO_HOST_GROK_DIR:-${HOME}/.grok}:ro
|
||||
- ${ROBOCO_DATA_DIR:-./data}/mcp-configs:/app/mcp-configs
|
||||
- ${ROBOCO_DATA_DIR:-./data}/prompts-generated:/app/prompts-generated
|
||||
- ${ROBOCO_DATA_DIR:-./data}/agent-settings:/app/agent-settings
|
||||
|
||||
+4
-3
@@ -349,9 +349,10 @@ services:
|
||||
- ${CLAUDE_AUTH_DIR:-/home/renzof/.claude}:/root/.claude
|
||||
# SuperGrok auth — mount the host ~/.grok at the SAME host path the
|
||||
# orchestrator passes to each Grok agent's `-v`, so its auth.json exists()
|
||||
# check passes here AND the agent bind resolves on the host. `grok login`
|
||||
# on the host writes auth.json (auto-refreshing). Read-only.
|
||||
- ${GROK_AUTH_DIR:-/home/renzof/.grok}:${ROBOCO_HOST_GROK_DIR:-/home/renzof/.grok}:ro
|
||||
# check passes here AND the agent bind resolves on the host. One canonical
|
||||
# var for source AND target (they must be equal in docker-in-docker); `grok
|
||||
# login` on the host writes auth.json (auto-refreshing). Read-only.
|
||||
- ${ROBOCO_HOST_GROK_DIR:-/home/renzof/.grok}:${ROBOCO_HOST_GROK_DIR:-/home/renzof/.grok}:ro
|
||||
# Shared config directory for MCP configs (writable)
|
||||
- ${ROBOCO_DATA_DIR:-./data}/mcp-configs:/app/mcp-configs
|
||||
# Generated prompts directory - composed at runtime from layers
|
||||
|
||||
+4
-3
@@ -349,9 +349,10 @@ services:
|
||||
- ${CLAUDE_AUTH_DIR:-/home/renzof/.claude}:/root/.claude
|
||||
# SuperGrok auth — mount the host ~/.grok at the SAME host path the
|
||||
# orchestrator passes to each Grok agent's `-v`, so its auth.json exists()
|
||||
# check passes here AND the agent bind resolves on the host. `grok login`
|
||||
# on the host writes auth.json (auto-refreshing). Read-only.
|
||||
- ${GROK_AUTH_DIR:-/home/renzof/.grok}:${ROBOCO_HOST_GROK_DIR:-/home/renzof/.grok}:ro
|
||||
# check passes here AND the agent bind resolves on the host. One canonical
|
||||
# var for source AND target (they must be equal in docker-in-docker); `grok
|
||||
# login` on the host writes auth.json (auto-refreshing). Read-only.
|
||||
- ${ROBOCO_HOST_GROK_DIR:-/home/renzof/.grok}:${ROBOCO_HOST_GROK_DIR:-/home/renzof/.grok}:ro
|
||||
# Shared config directory for MCP configs (writable)
|
||||
- ${ROBOCO_DATA_DIR:-./data}/mcp-configs:/app/mcp-configs
|
||||
# Generated prompts directory - composed at runtime from layers
|
||||
|
||||
@@ -1,33 +1,37 @@
|
||||
# LLM provider security posture
|
||||
|
||||
RoboCo routes each agent to one of several LLM providers (the **Routing** card in the control panel). This note states — truthfully — what protections an agent gets on each provider. The short version: **Grok now reaches effective security parity** — the command/secret-exfiltration guard, the budget/cost cap, and the prompt-injection guard all apply to Grok agents. The only Claude hook without an opencode equivalent is the stop-guard (terminal-verb enforcement), which is a workflow nicety, not a safety control. Any agent — including the delivery roles — can be routed to Grok.
|
||||
RoboCo routes each agent to one of several LLM providers (the **Routing** card in the control panel). This note states — truthfully — what protections an agent gets on each provider. The short version: **Grok now reaches effective security parity** — the command/secret-exfiltration guard, the budget/cost cap, and the prompt-injection guard all apply to Grok agents. The only Claude hook without a Grok equivalent is the stop-guard (terminal-verb enforcement), which is a workflow nicety, not a safety control. Any agent — including the delivery roles — can be routed to Grok.
|
||||
|
||||
## Two runtimes, not five
|
||||
|
||||
An agent has two layers: the **model** (the brain) and the **runtime** (the agent program that drives it — reads files, calls tools, loops). RoboCo's guardrails are implemented as **Claude Code hooks**, so they only exist when the runtime is Claude Code.
|
||||
An agent has two layers: the **model** (the brain) and the **runtime** (the agent program that drives it — reads files, calls tools, loops). RoboCo's guardrails are implemented as runtime hooks, so they only exist when the runtime provides them.
|
||||
|
||||
| Provider (routing mode) | Runtime | Model |
|
||||
|---|---|---|
|
||||
| Anthropic | Claude Code | Claude (opus/sonnet) |
|
||||
| Ollama (Cloud) | Claude Code (via `ANTHROPIC_BASE_URL` injection) | the Ollama model |
|
||||
| Self-Hosted | Claude Code (via `ANTHROPIC_BASE_URL` injection) | your endpoint's model |
|
||||
| **Grok (xAI)** | **opencode** | **grok-build-0.1** |
|
||||
| **Grok (xAI)** | **grok CLI** (Grok Build) | **grok-build** |
|
||||
|
||||
Anthropic, Ollama, and Self-Hosted all run on **Claude Code** and therefore keep the **full guard set**. Only **Grok** runs on a different runtime — **opencode** — because the `claude` binary rejects any non-Claude model id, so Grok cannot run inside Claude Code. opencode is to Grok what Claude Code is to Claude.
|
||||
Anthropic, Ollama, and Self-Hosted all run on **Claude Code** and therefore keep the **full guard set**. Only **Grok** runs on a different runtime — xAI's official **grok CLI** — because the `claude` binary rejects any non-Claude model id, so Grok cannot run inside Claude Code. The grok CLI is to Grok what Claude Code is to Claude, and (being Claude-Code-compatible) it supports the same blocking `PreToolUse` hook mechanism RoboCo's command guard relies on.
|
||||
|
||||
## Guardrail parity matrix
|
||||
|
||||
| Guardrail | Claude Code runtime (Anthropic / Ollama / Self-Hosted) | Grok (opencode) |
|
||||
| Guardrail | Claude Code runtime (Anthropic / Ollama / Self-Hosted) | Grok (grok CLI) |
|
||||
|---|---|---|
|
||||
| MCP gateway + role tool-manifest | yes | yes (mounted by construction) |
|
||||
| Command / secret-exfiltration guard (bash, credential files, internal-host calls, PAT exfil) | yes (`bash-guard-hook.sh`, PreToolUse) | **yes** — ported to opencode as the `secret-scrub.js` plugin (`tool.execute.before`) |
|
||||
| Budget / runaway-cost kill-switch | yes (`post-tool-budget` hook against the SDK server) | **yes** — orchestrator-side cost watchdog (`ROBOCO_GROK_MAX_COST_USD`) reading the opencode store |
|
||||
| Prompt-injection guard (rejects "ignore previous instructions", role-override, fake escalations in incoming A2A / task / notification content) | yes (`user-prompt-hook.sh`, UserPromptSubmit, denies the turn) | **yes** — recreated at RoboCo's input boundary (`prompt_guard.detect_injection`): the interactive driver scans every turn, the one-shot grok entrypoint scans the task prompt. Same patterns as the bash hook, kept in sync. opencode's lack of a blocking pre-prompt hook is irrelevant — we deny in our own code before calling the model |
|
||||
| Stop-guard (terminal-verb enforcement before a run ends) | yes (`stop-hook.sh`, Stop) | **no** — opencode's stop/idle hooks are observe-only (workflow nicety, not a security control) |
|
||||
| Command / secret-exfiltration guard (bash, credential files, internal-host calls, PAT exfil) | yes (`bash-guard-hook.sh`, PreToolUse) | **yes** — the SAME `bash-guard-hook.sh` installed as a grok blocking `PreToolUse` hook (`~/.grok/hooks/roboco-bash-guard.json`) for the exfil/credential/identity-forgery patterns, plus native `--deny` rules for git network/branch/history ops |
|
||||
| Budget / runaway-cost kill-switch | yes (`post-tool-budget` hook against the SDK server) | **yes** — orchestrator-side cost watchdog (`ROBOCO_GROK_MAX_COST_USD`) reading the captured `usage.json` |
|
||||
| Prompt-injection guard (rejects "ignore previous instructions", role-override, fake escalations in incoming A2A / task / notification content) | yes (`user-prompt-hook.sh`, UserPromptSubmit, denies the turn) | **yes** — recreated at RoboCo's input boundary (`prompt_guard.detect_injection`): the interactive driver scans every turn, the one-shot grok entrypoint scans the task prompt. Same patterns as the bash hook, kept in sync — independent of any runtime pre-prompt hook |
|
||||
| Stop-guard (terminal-verb enforcement before a run ends) | yes (`stop-hook.sh`, Stop) | **no** — the grok CLI's `Stop` event is observe-only / non-blocking (workflow nicety, not a security control) |
|
||||
|
||||
### Why the command guard splits git from exfil on Grok
|
||||
|
||||
The grok CLI deny mechanisms differ in one important way. Its native `--deny` rules deny **gracefully** — a blocked command returns a permission error and the agent recovers (adapts to the gateway verb). Its `PreToolUse` hook deny instead **cancels the whole run**. So git ops (a reflexive `git push` an agent must be able to recover from) stay on native `--deny`, while the exfil/credential patterns (a credential read or identity forgery — which no legitimate agent does) go through the hook, where a hard cancel is the correct response. The one shared script handles both: the hook runs with `ROBOCO_GUARD_SKIP_GIT=1` so it leaves git to `--deny`. (`--deny` matches a command prefix only, so the bash-guard's compound-command analysis applies on the Claude path; on Grok the exfil categories are still hook-analysed, and the PAT boundary is covered server-side regardless — PAT scrubbing, the role manifest, and X-Agent-* identity checks.)
|
||||
|
||||
## The remaining gap: the stop-guard
|
||||
|
||||
Every *security-relevant* Claude guard now applies to Grok — command/secret-exfiltration (`secret-scrub.js`), budget/runaway-cost (orchestrator cost watchdog), and prompt-injection (`prompt_guard`, recreated at the input boundary). The one Claude hook without an opencode equivalent is the **stop-guard** (it enforces that an agent calls a terminal MCP verb before a run ends), because opencode's session-stop events are observe-only. This is a workflow-completion guard, not a safety control: a Grok agent that ends without a terminal verb is recovered by the orchestrator reaper / idle watchdog, not left in a dangerous state.
|
||||
Every *security-relevant* Claude guard now applies to Grok — command/secret-exfiltration (the bash-guard hook + git `--deny`), budget/runaway-cost (orchestrator cost watchdog), and prompt-injection (`prompt_guard`, recreated at the input boundary). The one Claude hook without a Grok equivalent is the **stop-guard** (it enforces that an agent calls a terminal MCP verb before a run ends), because the grok CLI's session-stop events are observe-only. This is a workflow-completion guard, not a safety control: a Grok agent that ends without a terminal verb is recovered by the orchestrator reaper / idle watchdog, not left in a dangerous state.
|
||||
|
||||
## What this means for routing
|
||||
|
||||
|
||||
@@ -74,8 +74,8 @@ const AGENTS: { slug: string; label: string }[] = [
|
||||
{ slug: "ux-dev-1", label: "UX/UI Dev" },
|
||||
{ slug: "ux-qa", label: "UX/UI QA" },
|
||||
{ slug: "ux-doc", label: "UX/UI Documenter" },
|
||||
// Interactive (held-open chat) roles. Claude (SDK driver) and Grok (opencode
|
||||
// serve) are the supported runtimes; assigning a Grok model routes them to
|
||||
// Interactive (held-open chat) roles. Claude (SDK driver) and Grok (grok CLI)
|
||||
// are the supported runtimes; assigning a Grok model routes them to
|
||||
// the grok-prompter / grok-secretary image.
|
||||
{ slug: "intake-1", label: "Intake (Prompter)" },
|
||||
{ slug: "secretary-1", label: "Secretary" },
|
||||
@@ -493,7 +493,7 @@ export function AIRoutingCard() {
|
||||
) : null}
|
||||
{currentMode === "grok" || currentMode === "mix" ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Grok agents run on the opencode runtime; the command /
|
||||
Grok agents run on xAI's official grok CLI; the command /
|
||||
secret-exfiltration guard, the prompt-injection guard, and the
|
||||
per-agent cost cap all apply.
|
||||
</p>
|
||||
|
||||
@@ -28,6 +28,7 @@ write to ``usage.json`` wins (the orchestrator reads it back at reap).
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -46,6 +47,21 @@ if TYPE_CHECKING:
|
||||
logger = structlog.get_logger()
|
||||
|
||||
_DEFAULT_MODEL = os.environ.get("ROBOCO_AGENT_MODEL", "grok-build")
|
||||
# Per-turn watchdog default (seconds). A grok turn reasons + may call tools, so
|
||||
# this is generous; it only exists to recover a truly wedged process.
|
||||
_DEFAULT_TURN_TIMEOUT_SECONDS = 600.0
|
||||
|
||||
|
||||
def _turn_timeout_seconds() -> float:
|
||||
"""Per-turn grok watchdog timeout (ROBOCO_GROK_TURN_TIMEOUT_SECONDS)."""
|
||||
raw = os.environ.get("ROBOCO_GROK_TURN_TIMEOUT_SECONDS", "").strip()
|
||||
try:
|
||||
value = float(raw)
|
||||
except ValueError:
|
||||
return _DEFAULT_TURN_TIMEOUT_SECONDS
|
||||
return value if value > 0 else _DEFAULT_TURN_TIMEOUT_SECONDS
|
||||
|
||||
|
||||
# A rate-limit / quota end to a turn leaves no terminal verb on the one-shot path
|
||||
# and must read clearly on the interactive one; detected from the run's stderr.
|
||||
_RATE_LIMIT_MARKERS = (
|
||||
@@ -167,11 +183,22 @@ class GrokCliSession: # pragma: no cover - needs the live grok binary
|
||||
self._model = model
|
||||
self._usage_file = usage_file or os.environ.get("ROBOCO_GROK_USAGE_FILE")
|
||||
# The secretary's ROBOCO_AGENT_ID is its UUID (not a slug), so resolve the
|
||||
# role from the id when possible, else the container's ROBOCO_AGENT_ROLE.
|
||||
role = get_agent_role(agent_id) or os.environ.get("ROBOCO_AGENT_ROLE", "")
|
||||
# role from the id when it maps to a real one, else the container's
|
||||
# ROBOCO_AGENT_ROLE. get_agent_role returns the sentinel "unknown" (not
|
||||
# None) for an unmapped id, so treat that as a miss.
|
||||
resolved = get_agent_role(agent_id)
|
||||
role = (
|
||||
resolved
|
||||
if resolved and resolved != "unknown"
|
||||
else os.environ.get("ROBOCO_AGENT_ROLE", "")
|
||||
)
|
||||
self._role_args = grok_cli_args_for_role(role)
|
||||
self._extra_args = list(extra_args or [])
|
||||
self._session_id: str | None = None
|
||||
# Per-turn watchdog: a wedged grok process must not hang the whole chat
|
||||
# (the panel spinner only clears on turn_end). On expiry the turn is
|
||||
# killed and an error + turn_end are emitted.
|
||||
self._turn_timeout = _turn_timeout_seconds()
|
||||
|
||||
async def __aenter__(self) -> GrokCliSession:
|
||||
return self
|
||||
@@ -204,8 +231,8 @@ class GrokCliSession: # pragma: no cover - needs the live grok binary
|
||||
"""Run one turn (one ``grok -p`` invocation) and yield its chunks.
|
||||
|
||||
A turn always ends with a ``turn_end`` chunk; a failure (spawn error,
|
||||
non-zero exit, or no ``end`` event) yields an ``error`` chunk first so the
|
||||
panel never renders a blank turn.
|
||||
timeout, non-zero exit, or no ``end`` event) yields an ``error`` chunk
|
||||
first so the panel never renders a blank turn.
|
||||
"""
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
@@ -219,33 +246,84 @@ class GrokCliSession: # pragma: no cover - needs the live grok binary
|
||||
yield StreamChunk(kind="turn_end", data={})
|
||||
return
|
||||
|
||||
assembler = _StreamAssembler()
|
||||
assert proc.stdout is not None
|
||||
async for raw in proc.stdout:
|
||||
assert proc.stderr is not None
|
||||
# Drain stderr CONCURRENTLY: if grok writes more than the OS pipe buffer
|
||||
# (~64KB) to stderr while its stdout is still open, a sequential
|
||||
# drain-stdout-then-stderr would deadlock (grok blocks on the stderr
|
||||
# write, its stdout never reaches EOF, the turn hangs forever).
|
||||
stderr_task = asyncio.create_task(proc.stderr.read())
|
||||
|
||||
assembler = _StreamAssembler()
|
||||
timed_out = False
|
||||
try:
|
||||
async for chunk in self._drain(proc.stdout, assembler):
|
||||
yield chunk
|
||||
except TimeoutError:
|
||||
timed_out = True
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
proc.kill()
|
||||
|
||||
stderr = (await stderr_task).decode("utf-8", "replace")
|
||||
await proc.wait()
|
||||
if assembler.session_id:
|
||||
self._session_id = assembler.session_id
|
||||
self._capture_usage()
|
||||
|
||||
for chunk in self._finalize(timed_out, assembler, proc.returncode, stderr):
|
||||
yield chunk
|
||||
|
||||
async def _drain(
|
||||
self, stdout: asyncio.StreamReader, assembler: _StreamAssembler
|
||||
) -> AsyncIterator[StreamChunk]:
|
||||
"""Yield chunks from grok's stdout until EOF; raise on the turn deadline."""
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + self._turn_timeout
|
||||
while True:
|
||||
remaining = deadline - loop.time()
|
||||
if remaining <= 0:
|
||||
raise TimeoutError
|
||||
raw = await asyncio.wait_for(stdout.readline(), timeout=remaining)
|
||||
if not raw: # EOF
|
||||
return
|
||||
event = _parse_event(raw.decode("utf-8", "replace").strip())
|
||||
if event is None:
|
||||
continue
|
||||
for chunk in assembler.feed(event):
|
||||
yield chunk
|
||||
|
||||
stderr_bytes = await proc.stderr.read() if proc.stderr else b""
|
||||
stderr = stderr_bytes.decode("utf-8", "replace")
|
||||
await proc.wait()
|
||||
|
||||
if assembler.session_id:
|
||||
self._session_id = assembler.session_id
|
||||
self._capture_usage()
|
||||
|
||||
def _finalize(
|
||||
self,
|
||||
timed_out: bool,
|
||||
assembler: _StreamAssembler,
|
||||
returncode: int | None,
|
||||
stderr: str,
|
||||
) -> list[StreamChunk]:
|
||||
"""The error + turn_end chunks for an abnormal turn (empty if clean)."""
|
||||
if timed_out:
|
||||
logger.error(
|
||||
"grok turn timed out",
|
||||
agent_id=self._agent_id,
|
||||
timeout_s=self._turn_timeout,
|
||||
)
|
||||
return [
|
||||
StreamChunk(
|
||||
kind="error",
|
||||
text="The Grok turn timed out — please send your message again.",
|
||||
),
|
||||
StreamChunk(kind="turn_end", data={}),
|
||||
]
|
||||
if not assembler.saw_end:
|
||||
logger.error(
|
||||
"grok turn ended without a result",
|
||||
returncode=proc.returncode,
|
||||
returncode=returncode,
|
||||
stderr=stderr.strip()[:500],
|
||||
)
|
||||
yield StreamChunk(
|
||||
kind="error", text=_classify_failure(proc.returncode, stderr)
|
||||
)
|
||||
yield StreamChunk(kind="turn_end", data={})
|
||||
return [
|
||||
StreamChunk(kind="error", text=_classify_failure(returncode, stderr)),
|
||||
StreamChunk(kind="turn_end", data={}),
|
||||
]
|
||||
return []
|
||||
|
||||
def _capture_usage(self) -> None:
|
||||
"""Best-effort: rewrite usage.json with the chat's cumulative total."""
|
||||
|
||||
@@ -32,6 +32,7 @@ from roboco.llm.providers.grok_cli_config import (
|
||||
GROK_CONFIG_PATH,
|
||||
render_config_toml,
|
||||
write_agents_md,
|
||||
write_grok_hooks,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -84,8 +85,11 @@ async def main() -> None: # pragma: no cover - needs the live container + grok
|
||||
cwd = os.environ.get("ROBOCO_WORKSPACE", "/data/workspace")
|
||||
|
||||
_render_grok_config(base_url, session_id)
|
||||
# Install the role blueprint as grok's global system prompt (~/.grok/AGENTS.md).
|
||||
# Install the role blueprint as grok's global system prompt (~/.grok/AGENTS.md)
|
||||
# and the bash-guard PreToolUse hook (defense-in-depth: a no-op while shell is
|
||||
# disallowed, but survives any future shell re-enable, matching the one-shot path).
|
||||
write_agents_md()
|
||||
write_grok_hooks()
|
||||
|
||||
queue: asyncio.Queue[str | None] = asyncio.Queue()
|
||||
client = httpx.AsyncClient(timeout=30.0)
|
||||
|
||||
@@ -28,6 +28,7 @@ from roboco.llm.providers.grok_cli_config import (
|
||||
GROK_CONFIG_PATH,
|
||||
render_config_toml,
|
||||
write_agents_md,
|
||||
write_grok_hooks,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -82,8 +83,11 @@ async def main() -> None: # pragma: no cover - needs the live container + grok
|
||||
cwd = os.environ.get("ROBOCO_WORKSPACE", "/app")
|
||||
|
||||
_render_grok_config(base_url)
|
||||
# Install the role blueprint as grok's global system prompt (~/.grok/AGENTS.md).
|
||||
# Install the role blueprint as grok's global system prompt (~/.grok/AGENTS.md)
|
||||
# and the bash-guard PreToolUse hook (defense-in-depth; a no-op while shell is
|
||||
# disallowed, but survives any future shell re-enable, matching the one-shot path).
|
||||
write_agents_md()
|
||||
write_grok_hooks()
|
||||
|
||||
queue: asyncio.Queue[str | None] = asyncio.Queue()
|
||||
client = httpx.AsyncClient(timeout=30.0)
|
||||
|
||||
@@ -113,6 +113,8 @@ _GIT_MUTATE_DENY = (
|
||||
"Bash(git cherry-pick*)",
|
||||
"Bash(git revert*)",
|
||||
"Bash(git update-ref*)",
|
||||
"Bash(git tag -d*)",
|
||||
"Bash(git reflog delete*)",
|
||||
)
|
||||
_DESTRUCTIVE_DENY = ("Bash(rm -rf*)",)
|
||||
|
||||
|
||||
@@ -181,7 +181,7 @@ _GROK_INTERACTIVE_DOCKERFILES = {
|
||||
}
|
||||
|
||||
# A one-shot Grok container exits with this code (EX_TEMPFAIL) when the run hit
|
||||
# an xAI 429 (grok-agent-entrypoint.sh detects it). The orchestrator parks the
|
||||
# an xAI 429 (grok-cli-agent-entrypoint.sh detects it). The orchestrator parks the
|
||||
# grok provider rate-limited instead of crash-retrying, breaking the
|
||||
# 429 -> exit -> respawn cost loop. The probe-resume loop clears the park after
|
||||
# the retry window (unknown-provider time-expiry fallback in _probe_target).
|
||||
@@ -877,6 +877,20 @@ class AgentOrchestrator:
|
||||
img, f"{docker_dir}/{dockerfile}", build_context
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _grok_usage_dir(agent_id: str) -> Path:
|
||||
"""Per-agent grok usage dir, branched compose-vs-local.
|
||||
|
||||
Single source of truth for BOTH the pre-create/mount side
|
||||
(``_ensure_grok_usage_dir``) and the finalize read side
|
||||
(``_grok_usage_json``) so they can never drift: in compose the orchestrator
|
||||
sees the mounted host dir at ``GROK_USAGE_DATA_DIR``; in local mode the
|
||||
container's usage.json lands under the shared tempdir.
|
||||
"""
|
||||
if PROJECT_HOST_PATH:
|
||||
return Path(GROK_USAGE_DATA_DIR) / agent_id
|
||||
return Path(tempfile.gettempdir()) / "roboco-grok-usage" / agent_id
|
||||
|
||||
def _ensure_grok_usage_dir(self, agent_id: str) -> None:
|
||||
"""Pre-create the agent's grok usage dir (world-writable) before the mount.
|
||||
|
||||
@@ -884,13 +898,9 @@ class AgentOrchestrator:
|
||||
``root:root``, so the non-root ``agent`` user EACCESes when the grok
|
||||
entrypoint / interactive driver writes ``usage.json`` there. Creating the
|
||||
dir ``0777`` first makes the mounted dir writable regardless of the agent
|
||||
uid; the orchestrator (root) can still read it back at finalize. Mirrors
|
||||
the container-vs-local split in ``_resolve_host_paths``.
|
||||
uid; the orchestrator (root) can still read it back at finalize.
|
||||
"""
|
||||
if PROJECT_HOST_PATH:
|
||||
target = Path(GROK_USAGE_DATA_DIR) / agent_id
|
||||
else:
|
||||
target = Path(tempfile.gettempdir()) / "roboco-grok-usage" / agent_id
|
||||
target = self._grok_usage_dir(agent_id)
|
||||
try:
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
target.chmod(0o777)
|
||||
@@ -2117,6 +2127,13 @@ class AgentOrchestrator:
|
||||
initial_prompt: Optional initial prompt for the agent
|
||||
agent_settings_path: Path to per-agent Claude settings file
|
||||
"""
|
||||
# Every spawn gets a non-empty user prompt. A prompt-less spawn (e.g. the
|
||||
# crash auto-restart, which passes no initial_prompt) must still direct the
|
||||
# agent to scan for work. The Claude body re-applies the same default; doing
|
||||
# it here single-sources it so dedicated providers (GROK) get it too —
|
||||
# otherwise grok would launch with an empty `grok -p ""`.
|
||||
if not initial_prompt:
|
||||
initial_prompt = self._default_spawn_prompt()
|
||||
# A dedicated provider backend (e.g. GROK / OpenAI protocol) handles its
|
||||
# own spawn. Anthropic / Ollama Cloud / self-hosted have no dedicated
|
||||
# provider registered and fall through to the Claude Code body below,
|
||||
@@ -3888,10 +3905,11 @@ class AgentOrchestrator:
|
||||
"""Read a GROK agent's ``usage.json`` (``{model, total_tokens, cost_usd}``).
|
||||
|
||||
Written to the per-agent data dir by the grok-CLI entrypoint (one-shot,
|
||||
post-run) and the interactive driver (per-turn); the orchestrator sees it
|
||||
at ``GROK_USAGE_DATA_DIR``. Returns ``None`` when absent / unreadable.
|
||||
post-run) and the interactive driver (per-turn); read back from the same
|
||||
branched dir the writers mount (``_grok_usage_dir``). Returns ``None`` when
|
||||
absent / unreadable.
|
||||
"""
|
||||
usage_json = Path(GROK_USAGE_DATA_DIR) / agent_id / "usage.json"
|
||||
usage_json = self._grok_usage_dir(agent_id) / "usage.json"
|
||||
try:
|
||||
data = json.loads(usage_json.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
@@ -4857,7 +4875,7 @@ Start by:
|
||||
"""
|
||||
cid = instance.container_id[:12] if instance.container_id else None
|
||||
# Grok 429 parking (B4): a one-shot grok run that hit an xAI 429 exits
|
||||
# 75 (set by grok-agent-entrypoint.sh). Park the provider instead of
|
||||
# 75 (set by grok-cli-agent-entrypoint.sh). Park the provider instead of
|
||||
# crash-retrying so the spawn guard suppresses the respawn loop; the
|
||||
# probe-resume loop revives the task when the limit lifts.
|
||||
if self._is_grok_rate_limit_exit(instance, exit_code):
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
"""grok_cli_session — the pure streaming-json → StreamChunk mapper.
|
||||
|
||||
The subprocess runner (``GrokCliSession``) needs the live grok binary, so it is
|
||||
not gate-covered; the turn-mapping logic lives in the pure ``_StreamAssembler``
|
||||
and is fully exercised here by feeding it parsed events.
|
||||
The subprocess runner (``GrokCliSession.send``) needs the live grok binary, so it
|
||||
is not gate-covered; the turn-mapping logic lives in the pure ``_StreamAssembler``
|
||||
and is fully exercised here by feeding it parsed events. The synchronous
|
||||
``__init__`` (role resolution, per-role flags, timeout) IS pure and tested.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from roboco.agent_sdk.grok_cli_session import (
|
||||
GrokCliSession,
|
||||
_classify_failure,
|
||||
_parse_event,
|
||||
_StreamAssembler,
|
||||
_turn_timeout_seconds,
|
||||
)
|
||||
from roboco.llm.providers.grok_cli_config import grok_cli_args_for_role
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pytest
|
||||
|
||||
|
||||
def _kinds(chunks: list) -> list[str]:
|
||||
@@ -93,3 +101,40 @@ def test_classify_failure_generic_uses_last_stderr_line() -> None:
|
||||
assert "boom: the model exploded" in msg
|
||||
# With no stderr, the exit code is surfaced.
|
||||
assert "exit code 2" in _classify_failure(2, "")
|
||||
|
||||
|
||||
def test_session_resolves_role_from_env_when_id_is_a_uuid(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# The secretary's ROBOCO_AGENT_ID is a UUID; get_agent_role returns the
|
||||
# "unknown" sentinel for it, so the role must fall back to ROBOCO_AGENT_ROLE
|
||||
# (not silently use "unknown").
|
||||
monkeypatch.setenv("ROBOCO_AGENT_ROLE", "secretary")
|
||||
monkeypatch.delenv("ROBOCO_GROK_REASONING_EFFORT", raising=False)
|
||||
session = GrokCliSession(cwd="/app", agent_id="0192-uuid-not-a-slug")
|
||||
assert session._role_args == grok_cli_args_for_role("secretary")
|
||||
|
||||
|
||||
def test_session_uses_slug_role_when_id_maps(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("ROBOCO_AGENT_ROLE", raising=False)
|
||||
monkeypatch.delenv("ROBOCO_GROK_REASONING_EFFORT", raising=False)
|
||||
# intake-1 maps to the prompter role -> subagents allowed (not disallowed).
|
||||
session = GrokCliSession(cwd="/ws", agent_id="intake-1")
|
||||
dis = session._role_args[session._role_args.index("--disallowed-tools") + 1]
|
||||
assert "Agent" not in dis
|
||||
|
||||
|
||||
def test_turn_timeout_seconds_env_and_default(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("ROBOCO_GROK_TURN_TIMEOUT_SECONDS", raising=False)
|
||||
assert _turn_timeout_seconds() == 600.0 # noqa: PLR2004
|
||||
monkeypatch.setenv("ROBOCO_GROK_TURN_TIMEOUT_SECONDS", "120")
|
||||
assert _turn_timeout_seconds() == 120.0 # noqa: PLR2004
|
||||
# Garbage / non-positive falls back to the default.
|
||||
monkeypatch.setenv("ROBOCO_GROK_TURN_TIMEOUT_SECONDS", "nope")
|
||||
assert _turn_timeout_seconds() == 600.0 # noqa: PLR2004
|
||||
monkeypatch.setenv("ROBOCO_GROK_TURN_TIMEOUT_SECONDS", "0")
|
||||
assert _turn_timeout_seconds() == 600.0 # noqa: PLR2004
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Interactive grok entrypoints render the load-bearing MCP wiring into config.toml.
|
||||
|
||||
``_render_grok_config`` is the only synchronous, testable part of the interactive
|
||||
mains (``main()`` needs the live container). It must produce the exact MCP
|
||||
invocation the branch depends on — ``uv run --directory /app --no-sync`` (the
|
||||
ModuleNotFound guard), ``UV_PROJECT_ENVIRONMENT=/app/.venv``, and (secretary) the
|
||||
HMAC identity env the directive tools authenticate with.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tomllib
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from roboco.agent_sdk import grok_intake_main, grok_secretary_main
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_intake_render_wires_roboco_intake_mcp(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
cfg = tmp_path / ".grok" / "config.toml"
|
||||
monkeypatch.setattr(grok_intake_main, "GROK_CONFIG_PATH", cfg)
|
||||
grok_intake_main._render_grok_config("http://orch:8000", "sess-1")
|
||||
parsed = tomllib.loads(cfg.read_text())
|
||||
server = parsed["mcp_servers"]["roboco-intake"]
|
||||
assert server["command"] == "uv"
|
||||
# The ModuleNotFound guard: --directory /app + --no-sync, installed module.
|
||||
assert server["args"] == [
|
||||
"run",
|
||||
"--directory",
|
||||
"/app",
|
||||
"--no-sync",
|
||||
"python",
|
||||
"-m",
|
||||
"roboco.mcp.intake_server",
|
||||
]
|
||||
assert server["env"]["UV_PROJECT_ENVIRONMENT"] == "/app/.venv"
|
||||
assert server["env"]["ROBOCO_API_URL"] == "http://orch:8000"
|
||||
assert server["env"]["ROBOCO_PROMPTER_SESSION_ID"] == "sess-1"
|
||||
|
||||
|
||||
def test_secretary_render_wires_mcp_and_hmac_identity(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
cfg = tmp_path / ".grok" / "config.toml"
|
||||
monkeypatch.setattr(grok_secretary_main, "GROK_CONFIG_PATH", cfg)
|
||||
monkeypatch.setenv("ROBOCO_AGENT_ID", "uuid-sec")
|
||||
monkeypatch.setenv("ROBOCO_AGENT_ROLE", "secretary")
|
||||
monkeypatch.setenv("ROBOCO_AGENT_TOKEN", "hmac-xyz")
|
||||
grok_secretary_main._render_grok_config("http://orch:8000")
|
||||
parsed = tomllib.loads(cfg.read_text())
|
||||
server = parsed["mcp_servers"]["roboco-secretary"]
|
||||
assert server["args"] == [
|
||||
"run",
|
||||
"--directory",
|
||||
"/app",
|
||||
"--no-sync",
|
||||
"python",
|
||||
"-m",
|
||||
"roboco.mcp.secretary_server",
|
||||
]
|
||||
# The HMAC identity the directive tools authenticate with must flow through.
|
||||
assert server["env"]["ROBOCO_AGENT_TOKEN"] == "hmac-xyz"
|
||||
assert server["env"]["ROBOCO_AGENT_ID"] == "uuid-sec"
|
||||
assert server["env"]["ROBOCO_AGENT_ROLE"] == "secretary"
|
||||
assert server["env"]["UV_PROJECT_ENVIRONMENT"] == "/app/.venv"
|
||||
@@ -130,18 +130,21 @@ def test_max_turns_is_emitted() -> None:
|
||||
|
||||
|
||||
def test_bash_roles_deny_the_full_git_mutation_set() -> None:
|
||||
# Graceful native --deny rules (the agent recovers) covering the same git
|
||||
# network / branch / history ops the Claude bash-guard blocks.
|
||||
# Graceful native --deny rules (the agent recovers) covering the SAME git
|
||||
# network / branch / history ops the Claude bash-guard blocks — including the
|
||||
# tag-deletion / reflog-deletion the hook matches.
|
||||
args = gc.grok_cli_args_for_role("developer")
|
||||
for op in ("push", "fetch", "clone", "checkout", "merge", "rebase", "revert"):
|
||||
assert f"Bash(git {op}*)" in args
|
||||
assert "Bash(git tag -d*)" in args
|
||||
assert "Bash(git reflog delete*)" in args
|
||||
assert "Bash(rm -rf*)" in args
|
||||
|
||||
|
||||
def test_bash_guard_hook_config_skips_git() -> None:
|
||||
handler = gc.bash_guard_hook_config("/app/scripts/bash-guard-hook.sh")[
|
||||
"hooks"
|
||||
]["PreToolUse"][0]
|
||||
handler = gc.bash_guard_hook_config("/app/scripts/bash-guard-hook.sh")["hooks"][
|
||||
"PreToolUse"
|
||||
][0]
|
||||
assert handler["matcher"] == "Bash"
|
||||
inner = handler["hooks"][0]
|
||||
assert inner["command"] == "/app/scripts/bash-guard-hook.sh"
|
||||
|
||||
@@ -14,12 +14,18 @@ from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from roboco.models.runtime import AgentInstance
|
||||
from roboco.runtime.orchestrator import AgentOrchestrator, AgentState
|
||||
from roboco.runtime.orchestrator import (
|
||||
INTAKE_AGENT_ID,
|
||||
AgentOrchestrator,
|
||||
AgentState,
|
||||
)
|
||||
|
||||
|
||||
def _grok_instance(provider_type: str = "grok") -> AgentInstance:
|
||||
def _grok_instance(
|
||||
provider_type: str = "grok", agent_id: str = "be-dev-1"
|
||||
) -> AgentInstance:
|
||||
cfg = type("C", (), {"provider_type": provider_type, "model": "grok-build"})()
|
||||
return AgentInstance(agent_id="be-dev-1", state=AgentState.ACTIVE, config=cfg)
|
||||
return AgentInstance(agent_id=agent_id, state=AgentState.ACTIVE, config=cfg)
|
||||
|
||||
|
||||
def _orch(
|
||||
@@ -28,11 +34,12 @@ def _orch(
|
||||
cap: float,
|
||||
cost: float,
|
||||
provider_type: str = "grok",
|
||||
agent_id: str = "be-dev-1",
|
||||
) -> tuple[AgentOrchestrator, AsyncMock]:
|
||||
"""A bare orchestrator with the cost reader + container removal stubbed."""
|
||||
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
||||
orch._grok_max_cost_usd = cap
|
||||
orch._instances = {"be-dev-1": _grok_instance(provider_type)}
|
||||
orch._instances = {agent_id: _grok_instance(provider_type, agent_id)}
|
||||
monkeypatch.setattr(orch, "_grok_cost_usd", lambda _agent_id: cost)
|
||||
remove_mock = AsyncMock()
|
||||
monkeypatch.setattr(orch, "_remove_container", remove_mock)
|
||||
@@ -79,3 +86,42 @@ async def test_non_grok_container_is_ignored(monkeypatch: pytest.MonkeyPatch) ->
|
||||
|
||||
remove_mock.assert_not_awaited()
|
||||
assert "be-dev-1" in orch._instances
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kill_failure_keeps_instance_for_retry(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# If `docker rm` raises, the over-budget container must STAY registered so the
|
||||
# sweep retries next tick (the except `continue` resilience contract).
|
||||
orch, remove_mock = _orch(monkeypatch, cap=5.0, cost=7.5)
|
||||
remove_mock.side_effect = RuntimeError("docker rm failed")
|
||||
|
||||
await orch._enforce_grok_cost_budget()
|
||||
|
||||
remove_mock.assert_awaited_once()
|
||||
assert "be-dev-1" in orch._instances # not evicted -> retried next tick
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_interactive_kill_closes_the_relay(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# Killing an interactive (intake/secretary) container over budget must close
|
||||
# its panel relay with a reason so the chat ends cleanly, not a frozen SSE.
|
||||
orch, remove_mock = _orch(monkeypatch, cap=5.0, cost=9.0, agent_id=INTAKE_AGENT_ID)
|
||||
registry = type("R", (), {"calls": []})()
|
||||
registry.close_by_agent = lambda agent_id, error: registry.calls.append(
|
||||
(agent_id, error)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"roboco.services.prompter_live.get_live_registry", lambda: registry
|
||||
)
|
||||
|
||||
await orch._enforce_grok_cost_budget()
|
||||
|
||||
remove_mock.assert_awaited_once_with(f"roboco-agent-{INTAKE_AGENT_ID}")
|
||||
assert INTAKE_AGENT_ID not in orch._instances
|
||||
assert len(registry.calls) == 1
|
||||
assert registry.calls[0][0] == INTAKE_AGENT_ID
|
||||
assert "cost" in registry.calls[0][1].lower()
|
||||
|
||||
@@ -10,10 +10,12 @@ output (it bills at the output rate).
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from roboco.models.runtime import AgentInstance
|
||||
from roboco.runtime import orchestrator as orch_mod
|
||||
from roboco.runtime.orchestrator import AgentOrchestrator
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -75,3 +77,35 @@ async def test_resolve_final_usage_routes_grok_to_usage_json(
|
||||
|
||||
# No SDK fetch / transcript read for GROK — usage comes from usage.json.
|
||||
assert await orch._resolve_final_token_usage("be-dev-1") == (0, 12, 0, 0)
|
||||
|
||||
|
||||
def test_grok_usage_dir_branches_compose_vs_local(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(orch_mod, "PROJECT_HOST_PATH", "")
|
||||
local = AgentOrchestrator._grok_usage_dir("be-dev-1")
|
||||
assert "roboco-grok-usage" in str(local)
|
||||
assert local.name == "be-dev-1"
|
||||
|
||||
monkeypatch.setattr(orch_mod, "PROJECT_HOST_PATH", "/volume1/roboco")
|
||||
monkeypatch.setattr(orch_mod, "GROK_USAGE_DATA_DIR", "/data/grok-usage")
|
||||
assert str(AgentOrchestrator._grok_usage_dir("be-dev-1")) == (
|
||||
"/data/grok-usage/be-dev-1"
|
||||
)
|
||||
|
||||
|
||||
def test_grok_usage_json_reads_the_real_local_dir(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# The un-mocked read path must find usage.json in the SAME branched dir the
|
||||
# writer mounts (the local-mode fix: read side mirrors the write side).
|
||||
monkeypatch.setattr(orch_mod, "PROJECT_HOST_PATH", "")
|
||||
monkeypatch.setattr(tempfile, "gettempdir", lambda: str(tmp_path))
|
||||
udir = tmp_path / "roboco-grok-usage" / "be-dev-1"
|
||||
udir.mkdir(parents=True)
|
||||
(udir / "usage.json").write_text(
|
||||
json.dumps({"total_tokens": 55, "cost_usd": 0.1}), encoding="utf-8"
|
||||
)
|
||||
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
||||
assert orch._grok_usage_tokens("be-dev-1") == (0, 55, 0, 0)
|
||||
assert orch._grok_cost_usd("be-dev-1") == 0.1 # noqa: PLR2004
|
||||
|
||||
Reference in New Issue
Block a user