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
|
# 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.
|
# on the host (auth.json auto-refreshes). Every var below is optional.
|
||||||
|
|
||||||
# Host dir holding the SuperGrok auth. The orchestrator mounts <dir>/auth.json
|
# Host dir holding the SuperGrok auth (one canonical var). The compose mounts it
|
||||||
# read-only into each Grok agent's ~/.grok. GROK_AUTH_DIR is the host source the
|
# into the orchestrator at the same path, and the orchestrator hands that path to
|
||||||
# compose mounts into the orchestrator; keep both equal to the host's ~/.grok.
|
# each Grok agent's auth.json bind — so it must be the real host ~/.grok.
|
||||||
# ROBOCO_HOST_GROK_DIR=/home/youruser/.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.
|
# Image the orchestrator spawns for Grok agents, and the CLI model id.
|
||||||
# ROBOCO_GROK_AGENT_IMAGE=roboco-agent-grok:latest
|
# 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
|
# 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
|
# 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.
|
# 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}/mcp-configs:/app/mcp-configs
|
||||||
- ${ROBOCO_DATA_DIR:-./data}/prompts-generated:/app/prompts-generated
|
- ${ROBOCO_DATA_DIR:-./data}/prompts-generated:/app/prompts-generated
|
||||||
- ${ROBOCO_DATA_DIR:-./data}/agent-settings:/app/agent-settings
|
- ${ROBOCO_DATA_DIR:-./data}/agent-settings:/app/agent-settings
|
||||||
|
|||||||
+4
-3
@@ -349,9 +349,10 @@ services:
|
|||||||
- ${CLAUDE_AUTH_DIR:-/home/renzof/.claude}:/root/.claude
|
- ${CLAUDE_AUTH_DIR:-/home/renzof/.claude}:/root/.claude
|
||||||
# SuperGrok auth — mount the host ~/.grok at the SAME host path the
|
# 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()
|
# 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`
|
# check passes here AND the agent bind resolves on the host. One canonical
|
||||||
# on the host writes auth.json (auto-refreshing). Read-only.
|
# var for source AND target (they must be equal in docker-in-docker); `grok
|
||||||
- ${GROK_AUTH_DIR:-/home/renzof/.grok}:${ROBOCO_HOST_GROK_DIR:-/home/renzof/.grok}:ro
|
# 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)
|
# Shared config directory for MCP configs (writable)
|
||||||
- ${ROBOCO_DATA_DIR:-./data}/mcp-configs:/app/mcp-configs
|
- ${ROBOCO_DATA_DIR:-./data}/mcp-configs:/app/mcp-configs
|
||||||
# Generated prompts directory - composed at runtime from layers
|
# Generated prompts directory - composed at runtime from layers
|
||||||
|
|||||||
+4
-3
@@ -349,9 +349,10 @@ services:
|
|||||||
- ${CLAUDE_AUTH_DIR:-/home/renzof/.claude}:/root/.claude
|
- ${CLAUDE_AUTH_DIR:-/home/renzof/.claude}:/root/.claude
|
||||||
# SuperGrok auth — mount the host ~/.grok at the SAME host path the
|
# 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()
|
# 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`
|
# check passes here AND the agent bind resolves on the host. One canonical
|
||||||
# on the host writes auth.json (auto-refreshing). Read-only.
|
# var for source AND target (they must be equal in docker-in-docker); `grok
|
||||||
- ${GROK_AUTH_DIR:-/home/renzof/.grok}:${ROBOCO_HOST_GROK_DIR:-/home/renzof/.grok}:ro
|
# 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)
|
# Shared config directory for MCP configs (writable)
|
||||||
- ${ROBOCO_DATA_DIR:-./data}/mcp-configs:/app/mcp-configs
|
- ${ROBOCO_DATA_DIR:-./data}/mcp-configs:/app/mcp-configs
|
||||||
# Generated prompts directory - composed at runtime from layers
|
# Generated prompts directory - composed at runtime from layers
|
||||||
|
|||||||
@@ -1,33 +1,37 @@
|
|||||||
# LLM provider security posture
|
# 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
|
## 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 |
|
| Provider (routing mode) | Runtime | Model |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Anthropic | Claude Code | Claude (opus/sonnet) |
|
| Anthropic | Claude Code | Claude (opus/sonnet) |
|
||||||
| Ollama (Cloud) | Claude Code (via `ANTHROPIC_BASE_URL` injection) | the Ollama model |
|
| 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 |
|
| 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 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) |
|
| 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`) |
|
| 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 opencode store |
|
| 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. opencode's lack of a blocking pre-prompt hook is irrelevant — we deny in our own code before calling the model |
|
| 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** — opencode's stop/idle hooks are observe-only (workflow nicety, not a security control) |
|
| 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
|
## 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
|
## 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-dev-1", label: "UX/UI Dev" },
|
||||||
{ slug: "ux-qa", label: "UX/UI QA" },
|
{ slug: "ux-qa", label: "UX/UI QA" },
|
||||||
{ slug: "ux-doc", label: "UX/UI Documenter" },
|
{ slug: "ux-doc", label: "UX/UI Documenter" },
|
||||||
// Interactive (held-open chat) roles. Claude (SDK driver) and Grok (opencode
|
// Interactive (held-open chat) roles. Claude (SDK driver) and Grok (grok CLI)
|
||||||
// serve) are the supported runtimes; assigning a Grok model routes them to
|
// are the supported runtimes; assigning a Grok model routes them to
|
||||||
// the grok-prompter / grok-secretary image.
|
// the grok-prompter / grok-secretary image.
|
||||||
{ slug: "intake-1", label: "Intake (Prompter)" },
|
{ slug: "intake-1", label: "Intake (Prompter)" },
|
||||||
{ slug: "secretary-1", label: "Secretary" },
|
{ slug: "secretary-1", label: "Secretary" },
|
||||||
@@ -493,7 +493,7 @@ export function AIRoutingCard() {
|
|||||||
) : null}
|
) : null}
|
||||||
{currentMode === "grok" || currentMode === "mix" ? (
|
{currentMode === "grok" || currentMode === "mix" ? (
|
||||||
<p className="text-xs text-muted-foreground">
|
<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
|
secret-exfiltration guard, the prompt-injection guard, and the
|
||||||
per-agent cost cap all apply.
|
per-agent cost cap all apply.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ write to ``usage.json`` wins (the orchestrator reads it back at reap).
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import contextlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -46,6 +47,21 @@ if TYPE_CHECKING:
|
|||||||
logger = structlog.get_logger()
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
_DEFAULT_MODEL = os.environ.get("ROBOCO_AGENT_MODEL", "grok-build")
|
_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
|
# 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.
|
# and must read clearly on the interactive one; detected from the run's stderr.
|
||||||
_RATE_LIMIT_MARKERS = (
|
_RATE_LIMIT_MARKERS = (
|
||||||
@@ -167,11 +183,22 @@ class GrokCliSession: # pragma: no cover - needs the live grok binary
|
|||||||
self._model = model
|
self._model = model
|
||||||
self._usage_file = usage_file or os.environ.get("ROBOCO_GROK_USAGE_FILE")
|
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
|
# 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 from the id when it maps to a real one, else the container's
|
||||||
role = get_agent_role(agent_id) or os.environ.get("ROBOCO_AGENT_ROLE", "")
|
# 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._role_args = grok_cli_args_for_role(role)
|
||||||
self._extra_args = list(extra_args or [])
|
self._extra_args = list(extra_args or [])
|
||||||
self._session_id: str | None = None
|
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:
|
async def __aenter__(self) -> GrokCliSession:
|
||||||
return self
|
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.
|
"""Run one turn (one ``grok -p`` invocation) and yield its chunks.
|
||||||
|
|
||||||
A turn always ends with a ``turn_end`` chunk; a failure (spawn error,
|
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
|
timeout, non-zero exit, or no ``end`` event) yields an ``error`` chunk
|
||||||
panel never renders a blank turn.
|
first so the panel never renders a blank turn.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
proc = await asyncio.create_subprocess_exec(
|
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={})
|
yield StreamChunk(kind="turn_end", data={})
|
||||||
return
|
return
|
||||||
|
|
||||||
assembler = _StreamAssembler()
|
|
||||||
assert proc.stdout is not None
|
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())
|
event = _parse_event(raw.decode("utf-8", "replace").strip())
|
||||||
if event is None:
|
if event is None:
|
||||||
continue
|
continue
|
||||||
for chunk in assembler.feed(event):
|
for chunk in assembler.feed(event):
|
||||||
yield chunk
|
yield chunk
|
||||||
|
|
||||||
stderr_bytes = await proc.stderr.read() if proc.stderr else b""
|
def _finalize(
|
||||||
stderr = stderr_bytes.decode("utf-8", "replace")
|
self,
|
||||||
await proc.wait()
|
timed_out: bool,
|
||||||
|
assembler: _StreamAssembler,
|
||||||
if assembler.session_id:
|
returncode: int | None,
|
||||||
self._session_id = assembler.session_id
|
stderr: str,
|
||||||
self._capture_usage()
|
) -> 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:
|
if not assembler.saw_end:
|
||||||
logger.error(
|
logger.error(
|
||||||
"grok turn ended without a result",
|
"grok turn ended without a result",
|
||||||
returncode=proc.returncode,
|
returncode=returncode,
|
||||||
stderr=stderr.strip()[:500],
|
stderr=stderr.strip()[:500],
|
||||||
)
|
)
|
||||||
yield StreamChunk(
|
return [
|
||||||
kind="error", text=_classify_failure(proc.returncode, stderr)
|
StreamChunk(kind="error", text=_classify_failure(returncode, stderr)),
|
||||||
)
|
StreamChunk(kind="turn_end", data={}),
|
||||||
yield StreamChunk(kind="turn_end", data={})
|
]
|
||||||
|
return []
|
||||||
|
|
||||||
def _capture_usage(self) -> None:
|
def _capture_usage(self) -> None:
|
||||||
"""Best-effort: rewrite usage.json with the chat's cumulative total."""
|
"""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,
|
GROK_CONFIG_PATH,
|
||||||
render_config_toml,
|
render_config_toml,
|
||||||
write_agents_md,
|
write_agents_md,
|
||||||
|
write_grok_hooks,
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
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")
|
cwd = os.environ.get("ROBOCO_WORKSPACE", "/data/workspace")
|
||||||
|
|
||||||
_render_grok_config(base_url, session_id)
|
_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_agents_md()
|
||||||
|
write_grok_hooks()
|
||||||
|
|
||||||
queue: asyncio.Queue[str | None] = asyncio.Queue()
|
queue: asyncio.Queue[str | None] = asyncio.Queue()
|
||||||
client = httpx.AsyncClient(timeout=30.0)
|
client = httpx.AsyncClient(timeout=30.0)
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from roboco.llm.providers.grok_cli_config import (
|
|||||||
GROK_CONFIG_PATH,
|
GROK_CONFIG_PATH,
|
||||||
render_config_toml,
|
render_config_toml,
|
||||||
write_agents_md,
|
write_agents_md,
|
||||||
|
write_grok_hooks,
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
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")
|
cwd = os.environ.get("ROBOCO_WORKSPACE", "/app")
|
||||||
|
|
||||||
_render_grok_config(base_url)
|
_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_agents_md()
|
||||||
|
write_grok_hooks()
|
||||||
|
|
||||||
queue: asyncio.Queue[str | None] = asyncio.Queue()
|
queue: asyncio.Queue[str | None] = asyncio.Queue()
|
||||||
client = httpx.AsyncClient(timeout=30.0)
|
client = httpx.AsyncClient(timeout=30.0)
|
||||||
|
|||||||
@@ -113,6 +113,8 @@ _GIT_MUTATE_DENY = (
|
|||||||
"Bash(git cherry-pick*)",
|
"Bash(git cherry-pick*)",
|
||||||
"Bash(git revert*)",
|
"Bash(git revert*)",
|
||||||
"Bash(git update-ref*)",
|
"Bash(git update-ref*)",
|
||||||
|
"Bash(git tag -d*)",
|
||||||
|
"Bash(git reflog delete*)",
|
||||||
)
|
)
|
||||||
_DESTRUCTIVE_DENY = ("Bash(rm -rf*)",)
|
_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
|
# 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
|
# grok provider rate-limited instead of crash-retrying, breaking the
|
||||||
# 429 -> exit -> respawn cost loop. The probe-resume loop clears the park after
|
# 429 -> exit -> respawn cost loop. The probe-resume loop clears the park after
|
||||||
# the retry window (unknown-provider time-expiry fallback in _probe_target).
|
# the retry window (unknown-provider time-expiry fallback in _probe_target).
|
||||||
@@ -877,6 +877,20 @@ class AgentOrchestrator:
|
|||||||
img, f"{docker_dir}/{dockerfile}", build_context
|
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:
|
def _ensure_grok_usage_dir(self, agent_id: str) -> None:
|
||||||
"""Pre-create the agent's grok usage dir (world-writable) before the mount.
|
"""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
|
``root:root``, so the non-root ``agent`` user EACCESes when the grok
|
||||||
entrypoint / interactive driver writes ``usage.json`` there. Creating the
|
entrypoint / interactive driver writes ``usage.json`` there. Creating the
|
||||||
dir ``0777`` first makes the mounted dir writable regardless of the agent
|
dir ``0777`` first makes the mounted dir writable regardless of the agent
|
||||||
uid; the orchestrator (root) can still read it back at finalize. Mirrors
|
uid; the orchestrator (root) can still read it back at finalize.
|
||||||
the container-vs-local split in ``_resolve_host_paths``.
|
|
||||||
"""
|
"""
|
||||||
if PROJECT_HOST_PATH:
|
target = self._grok_usage_dir(agent_id)
|
||||||
target = Path(GROK_USAGE_DATA_DIR) / agent_id
|
|
||||||
else:
|
|
||||||
target = Path(tempfile.gettempdir()) / "roboco-grok-usage" / agent_id
|
|
||||||
try:
|
try:
|
||||||
target.mkdir(parents=True, exist_ok=True)
|
target.mkdir(parents=True, exist_ok=True)
|
||||||
target.chmod(0o777)
|
target.chmod(0o777)
|
||||||
@@ -2117,6 +2127,13 @@ class AgentOrchestrator:
|
|||||||
initial_prompt: Optional initial prompt for the agent
|
initial_prompt: Optional initial prompt for the agent
|
||||||
agent_settings_path: Path to per-agent Claude settings file
|
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
|
# A dedicated provider backend (e.g. GROK / OpenAI protocol) handles its
|
||||||
# own spawn. Anthropic / Ollama Cloud / self-hosted have no dedicated
|
# own spawn. Anthropic / Ollama Cloud / self-hosted have no dedicated
|
||||||
# provider registered and fall through to the Claude Code body below,
|
# 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}``).
|
"""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,
|
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
|
post-run) and the interactive driver (per-turn); read back from the same
|
||||||
at ``GROK_USAGE_DATA_DIR``. Returns ``None`` when absent / unreadable.
|
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:
|
try:
|
||||||
data = json.loads(usage_json.read_text(encoding="utf-8"))
|
data = json.loads(usage_json.read_text(encoding="utf-8"))
|
||||||
except (OSError, json.JSONDecodeError):
|
except (OSError, json.JSONDecodeError):
|
||||||
@@ -4857,7 +4875,7 @@ Start by:
|
|||||||
"""
|
"""
|
||||||
cid = instance.container_id[:12] if instance.container_id else None
|
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
|
# 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
|
# crash-retrying so the spawn guard suppresses the respawn loop; the
|
||||||
# probe-resume loop revives the task when the limit lifts.
|
# probe-resume loop revives the task when the limit lifts.
|
||||||
if self._is_grok_rate_limit_exit(instance, exit_code):
|
if self._is_grok_rate_limit_exit(instance, exit_code):
|
||||||
|
|||||||
@@ -1,19 +1,27 @@
|
|||||||
"""grok_cli_session — the pure streaming-json → StreamChunk mapper.
|
"""grok_cli_session — the pure streaming-json → StreamChunk mapper.
|
||||||
|
|
||||||
The subprocess runner (``GrokCliSession``) needs the live grok binary, so it is
|
The subprocess runner (``GrokCliSession.send``) needs the live grok binary, so it
|
||||||
not gate-covered; the turn-mapping logic lives in the pure ``_StreamAssembler``
|
is not gate-covered; the turn-mapping logic lives in the pure ``_StreamAssembler``
|
||||||
and is fully exercised here by feeding it parsed events.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from roboco.agent_sdk.grok_cli_session import (
|
from roboco.agent_sdk.grok_cli_session import (
|
||||||
|
GrokCliSession,
|
||||||
_classify_failure,
|
_classify_failure,
|
||||||
_parse_event,
|
_parse_event,
|
||||||
_StreamAssembler,
|
_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]:
|
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
|
assert "boom: the model exploded" in msg
|
||||||
# With no stderr, the exit code is surfaced.
|
# With no stderr, the exit code is surfaced.
|
||||||
assert "exit code 2" in _classify_failure(2, "")
|
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:
|
def test_bash_roles_deny_the_full_git_mutation_set() -> None:
|
||||||
# Graceful native --deny rules (the agent recovers) covering the same git
|
# Graceful native --deny rules (the agent recovers) covering the SAME git
|
||||||
# network / branch / history ops the Claude bash-guard blocks.
|
# 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")
|
args = gc.grok_cli_args_for_role("developer")
|
||||||
for op in ("push", "fetch", "clone", "checkout", "merge", "rebase", "revert"):
|
for op in ("push", "fetch", "clone", "checkout", "merge", "rebase", "revert"):
|
||||||
assert f"Bash(git {op}*)" in args
|
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
|
assert "Bash(rm -rf*)" in args
|
||||||
|
|
||||||
|
|
||||||
def test_bash_guard_hook_config_skips_git() -> None:
|
def test_bash_guard_hook_config_skips_git() -> None:
|
||||||
handler = gc.bash_guard_hook_config("/app/scripts/bash-guard-hook.sh")[
|
handler = gc.bash_guard_hook_config("/app/scripts/bash-guard-hook.sh")["hooks"][
|
||||||
"hooks"
|
"PreToolUse"
|
||||||
]["PreToolUse"][0]
|
][0]
|
||||||
assert handler["matcher"] == "Bash"
|
assert handler["matcher"] == "Bash"
|
||||||
inner = handler["hooks"][0]
|
inner = handler["hooks"][0]
|
||||||
assert inner["command"] == "/app/scripts/bash-guard-hook.sh"
|
assert inner["command"] == "/app/scripts/bash-guard-hook.sh"
|
||||||
|
|||||||
@@ -14,12 +14,18 @@ from unittest.mock import AsyncMock
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from roboco.models.runtime import AgentInstance
|
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"})()
|
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(
|
def _orch(
|
||||||
@@ -28,11 +34,12 @@ def _orch(
|
|||||||
cap: float,
|
cap: float,
|
||||||
cost: float,
|
cost: float,
|
||||||
provider_type: str = "grok",
|
provider_type: str = "grok",
|
||||||
|
agent_id: str = "be-dev-1",
|
||||||
) -> tuple[AgentOrchestrator, AsyncMock]:
|
) -> tuple[AgentOrchestrator, AsyncMock]:
|
||||||
"""A bare orchestrator with the cost reader + container removal stubbed."""
|
"""A bare orchestrator with the cost reader + container removal stubbed."""
|
||||||
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
||||||
orch._grok_max_cost_usd = cap
|
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)
|
monkeypatch.setattr(orch, "_grok_cost_usd", lambda _agent_id: cost)
|
||||||
remove_mock = AsyncMock()
|
remove_mock = AsyncMock()
|
||||||
monkeypatch.setattr(orch, "_remove_container", remove_mock)
|
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()
|
remove_mock.assert_not_awaited()
|
||||||
assert "be-dev-1" in orch._instances
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import tempfile
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from roboco.models.runtime import AgentInstance
|
from roboco.models.runtime import AgentInstance
|
||||||
|
from roboco.runtime import orchestrator as orch_mod
|
||||||
from roboco.runtime.orchestrator import AgentOrchestrator
|
from roboco.runtime.orchestrator import AgentOrchestrator
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
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.
|
# 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)
|
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