diff --git a/.env.example b/.env.example index a800f537..1f151b70 100644 --- a/.env.example +++ b/.env.example @@ -83,6 +83,37 @@ ROBOCO_LOCAL_LLM_BASE_URL=http://localhost:11434/v1 ROBOCO_LOCAL_LLM_MODEL=glm-5:cloud ROBOCO_DEFAULT_EMBEDDING_MODEL=qwen3-embedding:0.6b +# ============================================================================= +# Grok (xAI) Provider — optional +# ============================================================================= +# RoboCo can run agents on grok-build-0.1 (xAI) via the opencode runtime instead +# of Claude Code. The xAI API key is NOT set here — store it encrypted per +# project from the panel (provider key), the same as the Ollama/Anthropic keys. +# Every var below is optional; defaults shown. + +# Image the orchestrator spawns for Grok agents. +# ROBOCO_GROK_AGENT_IMAGE=roboco-agent-grok:latest + +# opencode tool permissions for Grok agents: allow | ask | deny. Defaults are +# "allow"; tighten bash to "deny"/"ask" to fail closed on untrusted repos (the +# secret-scrub plugin is a denylist, not a full sandbox). +# ROBOCO_GROK_BASH_PERMISSION=allow +# ROBOCO_GROK_EDIT_PERMISSION=allow +# ROBOCO_GROK_EXTERNAL_DIR_PERMISSION=allow + +# Force one reasoning effort for ALL Grok agents: minimal | high | max (or empty +# for opencode's default). Empty = per-role: coordination/docs roles request +# "minimal" to cut reasoning cost, code roles keep full reasoning. +# ROBOCO_GROK_REASONING_EFFORT= + +# Kill a Grok agent container after this many seconds idle (no model call / +# stream) to reclaim a wedged one. Minimum 120. +# ROBOCO_GROK_IDLE_KILL_SECONDS=900 + +# Per-agent cost ceiling (USD) before the orchestrator kills the container; +# 0 disables. Backstops runaway-loop token burn. +# ROBOCO_GROK_MAX_COST_USD=0.0 + # ============================================================================= # Security # ============================================================================= diff --git a/docker/agent-grok.Dockerfile b/docker/agent-grok.Dockerfile index b4098b0e..7fa0ec82 100644 --- a/docker/agent-grok.Dockerfile +++ b/docker/agent-grok.Dockerfile @@ -15,7 +15,10 @@ USER root # opencode — the OpenAI-protocol agent runtime. grok-build-0.1 runs on opencode's # BUILT-IN xai provider (no custom provider block / npm needed), so only # opencode-ai is installed; it resolves the provider SDK at runtime. -RUN npm install -g opencode-ai \ +# Pinned: this version is the live-verified runtime (grok-build-0.1 on the NAS). +# Untrusted model output runs under it, so bump the pin deliberately, never float. +ARG OPENCODE_VERSION=1.17.8 +RUN npm install -g "opencode-ai@${OPENCODE_VERSION}" \ && npm cache clean --force \ && rm -rf /root/.npm /tmp/* diff --git a/docker/grok/budget-feed.js b/docker/grok/budget-feed.js index 1f66d4af..257346f8 100644 --- a/docker/grok/budget-feed.js +++ b/docker/grok/budget-feed.js @@ -18,10 +18,13 @@ // 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. +// Fail policy: the `after` POSTs never block (recording can't risk spend). The +// `before` gate fails OPEN by default, but fails CLOSED when ROBOCO_BUDGET_ENFORCE=1 +// (set by the one-shot entrypoint, which always starts the SDK budget server) and +// the budget server is unreachable — an unenforceable cost cap on a task agent is +// the one case worth halting for. Interactive serve images (intake / secretary) +// own :9000 for the human-turn receiver, run NO SDK budget server, and set no +// ENFORCE flag, so their tool calls always proceed (these POSTs 404 there). const SDK_URL = process.env.ROBOCO_SDK_URL || "http://localhost:9000"; @@ -68,7 +71,8 @@ function argsHash(args) { // 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. +// Verified live: opencode delivers MCP tools as "roboco-flow_" (underscore); +// the "." form and an mcp__ prefix are still handled defensively. function bareVerb(tool) { const mcp = tool.match(/^mcp__[a-z0-9-]+__(.+)$/); if (mcp) return mcp[1]; @@ -84,7 +88,19 @@ export const RobocoBudgetFeed = async () => { return { "tool.execute.before": async (input) => { const status = await sdk("GET", "/budget/status", null); - if (!status) return; // fail-open + if (!status) { + // One-shot delivery agents MUST have the in-container SDK budget server + // (the entrypoint starts it and exports ENFORCE=1). A missing signal + // there means the cost cap is unenforceable — fail CLOSED to stop an + // uncapped burn. Interactive serve agents set no flag → fail open. + if (process.env.ROBOCO_BUDGET_ENFORCE === "1") { + throw new Error( + "[Halt] budget server unreachable — failing closed to prevent " + + "uncapped token spend. Stop now with i_am_idle() or unclaim().", + ); + } + return; // fail-open (no budget server expected for this role) + } if (status.halt) { throw new Error( `[Halt] tool budget exhausted (${status.total}/${status.halt_threshold}). ` + diff --git a/docker/grok/secret-scrub.js b/docker/grok/secret-scrub.js index b24386ba..eb86ccd3 100644 --- a/docker/grok/secret-scrub.js +++ b/docker/grok/secret-scrub.js @@ -10,12 +10,13 @@ // command is `output.args.command`; for `read`/`edit` the path is // `output.args.filePath`. // -// Loaded via the generated opencode.json `plugin:` array (see -// roboco.llm.providers.opencode_config). The agent's bash permission can also -// be set to "deny"/"ask" via ROBOCO_GROK_BASH_PERMISSION as a second gate. +// Baked into the plugin auto-discovery dir (~/.config/opencode/plugin/) at image +// build (named export, opencode's convention) — the same route as budget-feed. +// The agent's bash permission is a second gate via ROBOCO_GROK_BASH_PERMISSION. // -// STATUS: unvalidated against a live opencode runtime. Confirm it actually -// fires in the live E2E spawn before pointing a Grok dev-agent at a real repo. +// STATUS: the plugin loads in the live runtime (same auto-discovery dir as the +// live-confirmed budget-feed), but the deny-on-match path has not yet blocked a +// real command on the NAS — confirm before trusting it as the sole bash gate. // Deny-on-match is fail-closed: a false positive blocks a legitimate command // (annoying, safe) rather than letting a dangerous one through. diff --git a/docker/grok/secretary-tools.js b/docker/grok/secretary-tools.js index fcabf3b0..0900e55e 100644 --- a/docker/grok/secretary-tools.js +++ b/docker/grok/secretary-tools.js @@ -18,9 +18,9 @@ // forwards the call. Each tool returns the backend JSON as a string the model // reads back (mirrors secretary_driver._text_result). // -// UNVERIFIED-LIVE: the @opencode-ai/plugin tool-registration path against a live -// opencode serve + grok-build-0.1 — confirm a submit_directive round-trips with -// the HMAC token on the NAS before routing real CEO directives through Grok. +// Verified live on the NAS: the @opencode-ai/plugin tool-registration path +// round-trips against a live opencode serve + grok-build-0.1 — a directive +// reaches the backend with the HMAC token and the JSON result returns to the model. import { tool } from "@opencode-ai/plugin"; diff --git a/docker/scripts/grok-agent-entrypoint.sh b/docker/scripts/grok-agent-entrypoint.sh index 6ac6e18e..a0d67a78 100755 --- a/docker/scripts/grok-agent-entrypoint.sh +++ b/docker/scripts/grok-agent-entrypoint.sh @@ -33,6 +33,12 @@ 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 +# This is a one-shot delivery agent: the SDK budget server above is mandatory. +# Tell the budget-feed plugin to FAIL CLOSED if that server ever goes +# unreachable mid-run, so an unenforceable cost cap halts the burn instead of +# letting it run uncapped. (Interactive serve images set no such flag.) +export ROBOCO_BUDGET_ENFORCE=1 + # Prompt-injection guard (parity with the Claude UserPromptSubmit hook): the # task prompt is DATA, not instructions — refuse a poisoned one before it # reaches the model. Same patterns as docker/scripts/user-prompt-hook.sh.