diff --git a/docker/agent-grok.Dockerfile b/docker/agent-grok.Dockerfile index 959ab668..8ad9be6e 100644 --- a/docker/agent-grok.Dockerfile +++ b/docker/agent-grok.Dockerfile @@ -21,9 +21,13 @@ RUN npm install -g opencode-ai @ai-sdk/openai \ && npm cache clean --force \ && rm -rf /root/.npm /tmp/* -# Command guard / secret-scrub plugin (bash-guard parity for the opencode runtime). -# Referenced from the generated opencode.json `plugin:` array. +# opencode plugins (referenced from the generated opencode.json `plugin:` array): +# secret-scrub — bash-guard parity (PAT/credential deny on tool.execute.before) +# budget-feed — POSTs budget/loop/terminal counters to the in-container SDK +# server (tool.execute.{before,after}); the entrypoint starts +# that server (roboco.agent_sdk.server) for Claude-parity. COPY docker/grok/secret-scrub.js /app/opencode-plugins/secret-scrub.js +COPY docker/grok/budget-feed.js /app/opencode-plugins/budget-feed.js # Entrypoint: render opencode.json, then run opencode (overrides base's `claude`). COPY docker/scripts/grok-agent-entrypoint.sh /app/scripts/grok-agent-entrypoint.sh diff --git a/docker/grok/budget-feed.js b/docker/grok/budget-feed.js new file mode 100644 index 00000000..b2e10780 --- /dev/null +++ b/docker/grok/budget-feed.js @@ -0,0 +1,108 @@ +// opencode plugin — budget / loop / terminal feed for one-shot RoboCo Grok agents. +// +// Ports the Claude PostToolUse budget hook (docker/scripts/post-tool-budget-hook.sh) +// and the Stop hook's terminal-tool tracking (docker/scripts/stop-hook.sh) to +// opencode's tool.execute.{before,after}. The in-container SDK server +// (roboco.agent_sdk.server, :9000) is the same long-lived process the Claude +// path runs — the grok entrypoint starts it before `opencode run`. The flow/do +// MCP servers already POST /verb/attempted to it for the per-verb circuit +// breaker, so starting it + this feed restores the budget/loop/terminal cluster +// on Grok (Claude-parity, the CEO's "create what's missing" call). +// +// before: read /budget/status and DENY (throw) on a hard halt, or a loop with +// loop_action=halt. opencode has no PostToolUse-deny, so the pre-exec +// gate is the only place to stop a runaway one-shot run from burning +// the cost cap mid-turn. The loop trips one call later than Claude +// (record is in `after`) but still halts the burn. +// after: record the executed tool on /terminal/tool_recorded (so a graceful +// terminal verb is recognized) and /budget/tool_called (advances the +// breaker/loop counters and feeds the post-exit post-mortem). +// +// Fail-open everywhere: a missing / slow / non-2xx SDK never blocks the agent. +// The interactive serve images (intake / secretary) own :9000 for the human-turn +// receiver and run NO SDK server, so these POSTs 404 there and are ignored — +// those roles don't claim tasks or loop on verbs, so they need no budget feed. + +const SDK_URL = process.env.ROBOCO_SDK_URL || "http://localhost:9000"; + +async function sdk(method, path, body) { + try { + const res = await fetch(`${SDK_URL}${path}`, { + method, + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + signal: AbortSignal.timeout(2000), + }); + if (!res.ok) return null; + return await res.json(); + } catch { + return null; // fail-open — never block the agent on SDK reachability + } +} + +// Canonical, dependency-free serialization (sorted keys) so the SDK's loop +// detector sees identical (tool, args) calls as identical. Need NOT match the +// Claude hook's sha256 — only be stable within one session. +function canonical(value) { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return "[" + value.map(canonical).join(",") + "]"; + const keys = Object.keys(value).sort(); + return ( + "{" + + keys.map((k) => JSON.stringify(k) + ":" + canonical(value[k])).join(",") + + "}" + ); +} + +function argsHash(args) { + const s = canonical(args ?? {}); + let h = 0x811c9dc5; // FNV-1a, 32-bit + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return (h >>> 0).toString(16).padStart(8, "0"); +} + +// opencode namespaces an MCP tool as "_" (or "."); +// the Claude path's verbs arrive bare. Strip a known roboco-* server prefix so +// the SDK recognizes a terminal verb (i_am_idle / i_am_done / ...) — the SDK's +// own "__"-split is a no-op on the already-bare verb this returns. +// UNVERIFIED-LIVE: opencode's exact MCP tool-name shape; the strip is defensive. +function bareVerb(tool) { + const mcp = tool.match(/^mcp__[a-z0-9-]+__(.+)$/); + if (mcp) return mcp[1]; + const pref = tool.match(/^roboco-[a-z-]+[_.](.+)$/); + if (pref) return pref[1]; + return tool; +} + +export default async () => { + return { + "tool.execute.before": async (input) => { + const status = await sdk("GET", "/budget/status", null); + if (!status) return; // fail-open + if (status.halt) { + throw new Error( + `[Halt] tool budget exhausted (${status.total}/${status.halt_threshold}). ` + + "Stop now — release the task with unclaim() or i_am_idle().", + ); + } + if (status.loop && status.loop_action === "halt") { + throw new Error( + "[Loop] same tool+args repeated in window — halting. " + + "Release the task with unclaim() or stop with i_am_idle().", + ); + } + }, + "tool.execute.after": async (input) => { + const raw = String(input?.tool || ""); + if (!raw) return; + await sdk("POST", "/terminal/tool_recorded", { tool: bareVerb(raw) }); + await sdk("POST", "/budget/tool_called", { + tool: bareVerb(raw), + args_hash: argsHash(input?.args), + }); + }, + }; +}; diff --git a/docker/scripts/grok-agent-entrypoint.sh b/docker/scripts/grok-agent-entrypoint.sh index c93fbdbf..e75ac877 100755 --- a/docker/scripts/grok-agent-entrypoint.sh +++ b/docker/scripts/grok-agent-entrypoint.sh @@ -1,33 +1,37 @@ #!/usr/bin/env bash -# Entrypoint for the roboco-agent-grok image. +# Entrypoint for the roboco-agent-grok image (one-shot delivery roles). # # Renders opencode.json from the RoboCo spawn env (OPENAI_* + ROBOCO_*, set by -# GrokProvider) plus the mounted Claude Code mcp-config.json, then runs opencode -# non-interactively. opencode speaks the OpenAI protocol, so grok-build-0.1 runs -# natively against api.x.ai/v1 with no shim, while still reaching the RoboCo MCP -# gateway (roboco-flow / roboco-do / ...) translated into opencode's mcp config. +# GrokProvider) plus the mounted Claude Code mcp-config.json, starts the +# in-container SDK server (parity with the Claude SessionStart sdk-startup-hook), +# then runs opencode non-interactively. opencode speaks the OpenAI protocol, so +# grok-build-0.1 runs natively against api.x.ai/v1 with no shim, while still +# reaching the RoboCo MCP gateway (roboco-flow / roboco-do / ...) translated into +# opencode's mcp config. set -euo pipefail +SDK_PORT="${ROBOCO_SDK_PORT:-9000}" +SDK_URL="http://localhost:${SDK_PORT}" + # Generate opencode.json (provider + model + MCP gateway + permissions + # instructions). Writes to opencode's global config dir by default. python -m roboco.llm.providers.opencode_config -# Run the agent. The prompt comes from an env var (never an untrusted argv -# positional); `--` separates it from flags so a prompt starting with `--` -# cannot be parsed as CLI options. The model also comes from the rendered -# config; --model is passed explicitly as belt-and-suspenders. -# -# `< /dev/null` is REQUIRED: without a closed stdin, `opencode run` hangs after -# init in a headless / no-TTY environment (it blocks waiting on stdin). Verified -# live — closing stdin lets the run proceed to the model call and exit cleanly. -# -# Reasoning effort: GrokProvider sets ROBOCO_GROK_VARIANT per role (e.g. -# "minimal" for coordination/docs roles to cut reasoning cost). Absent = -# opencode default (full reasoning). -variant_arg=() -if [ -n "${ROBOCO_GROK_VARIANT:-}" ]; then - variant_arg=(--variant "$ROBOCO_GROK_VARIANT") +# --- SDK server bring-up (Claude-parity) ---------------------------------- +# The flow/do MCP servers POST /verb/attempted here for the per-verb circuit +# breaker; the budget-feed opencode plugin POSTs /budget/* + /terminal/* here; +# the post-exit hook below reads /terminal/status and writes the post-mortem. +# Bare `python` (the baked venv) — NOT `uv run`, which would re-sync the clone's +# drifted lock and stall (the #179 fix the Claude hook needs `--no-sync` for). +if ! curl -sf -m 2 "${SDK_URL}/health" >/dev/null 2>&1; then + nohup python -m roboco.agent_sdk.server >/tmp/sdk-server.log 2>&1 & + for _ in 1 2 3 4 5 6 7 8 9 10; do + if curl -sf -m 2 "${SDK_URL}/health" >/dev/null 2>&1; then break; fi + sleep 0.5 + done fi +# Zero the budget/terminal counters at the start of the session. +curl -sf -m 2 -X POST "${SDK_URL}/budget/reset" >/dev/null 2>&1 || true # Prompt-injection guard (parity with the Claude UserPromptSubmit hook): the # task prompt is DATA, not instructions — refuse a poisoned one before it @@ -37,7 +41,54 @@ if ! python -m roboco.agent_sdk.prompt_guard "${ROBOCO_INITIAL_PROMPT:-}"; then exit 1 fi -exec opencode run \ +# Reasoning effort: GrokProvider sets ROBOCO_GROK_VARIANT per role (e.g. +# "minimal" for coordination/docs roles to cut reasoning cost). Absent = +# opencode default (full reasoning). +variant_arg=() +if [ -n "${ROBOCO_GROK_VARIANT:-}" ]; then + variant_arg=(--variant "$ROBOCO_GROK_VARIANT") +fi + +# Run the agent. The prompt comes from an env var (never an untrusted argv +# positional); `--` separates it from flags so a prompt starting with `--` +# cannot be parsed as CLI options. `< /dev/null` is REQUIRED: without a closed +# stdin, `opencode run` hangs after init in a headless / no-TTY environment. +# +# We do NOT `exec`: the script must regain control after opencode exits to run +# the post-mortem + silent-exit substitute below (the Claude SessionEnd / Stop +# hooks have no opencode equivalent, so the boundary handles them). `set +e` +# around the run so a non-zero opencode exit doesn't abort before the post-run +# hooks; PIPESTATUS preserves opencode's real code through the `tee`. +set +e +opencode run \ --model "xai/${ROBOCO_AGENT_MODEL:-grok-build-0.1}" \ "${variant_arg[@]}" \ -- "${ROBOCO_INITIAL_PROMPT:-}" < /dev/null +run_rc=$? +set -e + +# --- Post-run hooks (Claude SessionEnd + Stop parity) ---------------------- +# Read terminal state once, then (a) write a post-mortem journal entry and +# (b) if the agent exited WITHOUT a terminal verb (i_am_idle / i_am_done / +# pass / fail / ...), auto-substitute the task so it is not left stuck in +# claimed/in_progress for a human to hand-unstick. Best-effort; never change +# the exit code the orchestrator observes. +terminal=$(curl -sf -m 2 "${SDK_URL}/terminal/status" 2>/dev/null || echo "") +last_tool="null" +had_terminal="false" +if [ -n "$terminal" ]; then + last_tool=$(echo "$terminal" | jq -r '.last_tool // "null"' 2>/dev/null || echo "null") + had_terminal=$(echo "$terminal" | jq -r '.had_terminal_recently // false' 2>/dev/null || echo "false") +fi + +curl -sf -m 3 -X POST "${SDK_URL}/journal/post_mortem" \ + -H "Content-Type: application/json" \ + -d "{\"terminal_tool\":\"${last_tool}\",\"reason\":\"session_end\"}" \ + >/dev/null 2>&1 || true + +if [ "$had_terminal" != "true" ]; then + curl -sf -m 3 -X POST "${SDK_URL}/terminal/force_substitute" >/dev/null 2>&1 || true + echo "[grok] exited without a terminal verb (last tool: ${last_tool}) — auto-substituted." >&2 +fi + +exit "$run_rc" diff --git a/roboco/llm/providers/opencode_config.py b/roboco/llm/providers/opencode_config.py index 7530d2c2..c7885923 100644 --- a/roboco/llm/providers/opencode_config.py +++ b/roboco/llm/providers/opencode_config.py @@ -21,15 +21,23 @@ Config shape per opencode docs (https://opencode.ai/docs/config): review). The request/stream timeouts below are the defence-in-depth backstop. GUARDRAIL PARITY: the bash-guard (PAT-scrub) is ported via ``secret-scrub.js`` -(``tool.execute.before``); usage/cost is captured from opencode's SQLite store at -finalize and bounded by the orchestrator cost watchdog -(``ROBOCO_GROK_MAX_COST_USD``, which also catches runaway-loop burn); and the -prompt-injection guard is recreated at RoboCo's input boundary -(``roboco.agent_sdk.prompt_guard`` — the driver scans interactive turns, the -entrypoint scans the one-shot task prompt). The only Claude hook without an -opencode equivalent is the stop-guard (terminal-verb enforcement; opencode's -stop events are observe-only) — a workflow nicety, not a security control. -``bash`` permission stays operator-tunable (``ROBOCO_GROK_BASH_PERMISSION``). +(``tool.execute.before``); the per-session budget / loop / terminal-verb +counters and the per-verb circuit breaker are restored by starting the same +in-container SDK server the Claude path runs (the grok entrypoint launches +``roboco.agent_sdk.server``) and feeding it from ``budget-feed.js`` +(``tool.execute.{before,after}``); usage/cost is captured from opencode's SQLite +store at finalize and bounded by the orchestrator cost watchdog +(``ROBOCO_GROK_MAX_COST_USD``); the prompt-injection guard is recreated at +RoboCo's input boundary (``roboco.agent_sdk.prompt_guard``); and the SessionEnd +post-mortem + Stop silent-exit substitute run at the entrypoint boundary after +``opencode run`` returns. ``bash`` / ``edit`` permissions are scoped per role +(read-only roles get ``edit=deny``; only delivery roles get ``bash``) and stay +operator-tunable (``ROBOCO_GROK_BASH_PERMISSION`` / ``ROBOCO_GROK_EDIT_PERMISSION``). + +Per-image extra plugins (the Secretary's directive tools, the Intake's +``propose_draft``) are appended via ``ROBOCO_OPENCODE_EXTRA_PLUGINS`` (a +``os.pathsep``-separated list), set in those images' Dockerfiles so the tools +are scoped to the one role that should have them. """ from __future__ import annotations @@ -49,8 +57,29 @@ _PROVIDER_ID = "xai" _PROVIDER_NPM = "@ai-sdk/openai" # Plugins baked into the roboco-agent-grok image (see docker/agent-grok.Dockerfile). -# secret-scrub ports the bash-guard deny rules to opencode's tool.execute.before. -_PLUGINS = ["/app/opencode-plugins/secret-scrub.js"] +# secret-scrub ports the bash-guard deny rules to opencode's tool.execute.before; +# budget-feed POSTs the budget/loop/terminal counters to the in-container SDK +# server (tool.execute.{before,after}). Per-image extras (secretary / intake +# tools) are appended from ROBOCO_OPENCODE_EXTRA_PLUGINS (see _extra_plugins). +_PLUGINS = [ + "/app/opencode-plugins/secret-scrub.js", + "/app/opencode-plugins/budget-feed.js", +] + + +def _extra_plugins() -> list[str]: + """Image-scoped plugin paths from ``ROBOCO_OPENCODE_EXTRA_PLUGINS``. + + An ``os.pathsep``-separated list set in an interactive image's Dockerfile so + a role-specific tool plugin (the Secretary's directive tools, the Intake's + ``propose_draft``) is loaded only for that one role. Blank/missing yields no + extras. Mirrors the way the manifest scopes verbs per role. + """ + raw = os.environ.get("ROBOCO_OPENCODE_EXTRA_PLUGINS", "").strip() + if not raw: + return [] + return [p for p in raw.split(os.pathsep) if p.strip()] + # opencode's built-in subagent-spawning tool. Hard-disabled in the generated # config (see the module docstring): a RoboCo agent never spawns opencode's own @@ -141,9 +170,11 @@ def build_opencode_config( *, instruction_paths: list[str], guards: OpencodeGuards | None = None, + extra_plugins: list[str] | None = None, ) -> dict[str, Any]: """Build the full ``opencode.json`` dict for a Grok agent.""" guards = guards or OpencodeGuards() + plugins = [*_PLUGINS, *(extra_plugins or [])] config: dict[str, Any] = { "$schema": _OPENCODE_SCHEMA, "provider": { @@ -171,8 +202,9 @@ def build_opencode_config( "external_directory": guards.external_directory_permission, }, "instructions": instruction_paths, - # Command guard / secret-scrub (bash-guard parity). Baked into the image. - "plugin": list(_PLUGINS), + # secret-scrub (bash-guard parity) + budget-feed (SDK budget/loop feed), + # baked into the runtime image, plus any image-scoped role tool plugins. + "plugin": plugins, } if guards.disable_subagents: # Remove the subagent tool entirely so the model can never invoke it. @@ -207,6 +239,7 @@ def main() -> int: ) guards = OpencodeGuards( bash_permission=os.environ.get("ROBOCO_GROK_BASH_PERMISSION", "allow"), + edit_permission=os.environ.get("ROBOCO_GROK_EDIT_PERMISSION", "allow"), external_directory_permission=os.environ.get( "ROBOCO_GROK_EXTERNAL_DIR_PERMISSION", "allow" ), @@ -227,6 +260,7 @@ def main() -> int: target, instruction_paths=instructions, guards=guards, + extra_plugins=_extra_plugins(), ) out = Path(out_path) out.parent.mkdir(parents=True, exist_ok=True) diff --git a/tests/unit/llm/test_opencode_config.py b/tests/unit/llm/test_opencode_config.py index 9f5f823a..dd00760b 100644 --- a/tests/unit/llm/test_opencode_config.py +++ b/tests/unit/llm/test_opencode_config.py @@ -11,6 +11,7 @@ from roboco.llm.providers.opencode_config import ( OpencodeGuards, XaiTarget, _env_int, + _extra_plugins, build_opencode_config, translate_mcp_servers, ) @@ -88,8 +89,52 @@ def test_build_opencode_config_provider_and_model() -> None: # Gateway servers carried through. assert "roboco-flow" in cfg["mcp"] assert cfg["instructions"] == ["/app/system-prompt.md"] - # The secret-scrub command guard is wired in by default. - assert cfg["plugin"] == ["/app/opencode-plugins/secret-scrub.js"] + # The secret-scrub command guard + the SDK budget-feed are wired in by default. + assert cfg["plugin"] == [ + "/app/opencode-plugins/secret-scrub.js", + "/app/opencode-plugins/budget-feed.js", + ] + + +def test_build_opencode_config_appends_extra_plugins() -> None: + # Per-image role tool plugins (secretary directive tools, intake propose_draft) + # append AFTER the baked defaults so the role-scoped tools load too. + cfg = build_opencode_config( + _MCP, + _TARGET, + instruction_paths=[], + extra_plugins=["/app/opencode-plugins/secretary-tools.js"], + ) + assert cfg["plugin"] == [ + "/app/opencode-plugins/secret-scrub.js", + "/app/opencode-plugins/budget-feed.js", + "/app/opencode-plugins/secretary-tools.js", + ] + + +def test_extra_plugins_reads_pathsep_env() -> None: + with patch.dict(os.environ, {}, clear=True): + assert _extra_plugins() == [] + joined = os.pathsep.join(["/a/one.js", "/b/two.js"]) + with patch.dict(os.environ, {"ROBOCO_OPENCODE_EXTRA_PLUGINS": joined}): + assert _extra_plugins() == ["/a/one.js", "/b/two.js"] + # Blank entries are dropped (a trailing pathsep or empty override is benign). + with patch.dict( + os.environ, {"ROBOCO_OPENCODE_EXTRA_PLUGINS": f"/a/one.js{os.pathsep} "} + ): + assert _extra_plugins() == ["/a/one.js"] + + +def test_build_opencode_config_edit_permission_is_tunable() -> None: + # Read-only roles (qa / pr_reviewer / auditor / PMs / board) get edit=deny so + # a Grok agent can't write code on a role that must never touch the tree. + cfg = build_opencode_config( + {}, + _TARGET, + instruction_paths=[], + guards=OpencodeGuards(edit_permission="deny"), + ) + assert cfg["permission"]["edit"] == "deny" def test_build_opencode_config_bash_permission_is_tunable() -> None: