feat(grok): start the in-container SDK server + budget feed (Claude parity)

The keystone of the Grok parity work (CEO's "take Claude as baseline, create
what's missing" call): the one-shot Grok container now starts the same SDK
server the Claude path runs, so the per-verb circuit breaker (the flow/do MCP
servers already POST /verb/attempted to it), the per-session budget/loop
counters, the terminal-verb tracking, and the SessionEnd post-mortem all work
on Grok instead of being silently absent.

- entrypoint: launch roboco.agent_sdk.server (bare venv python, not `uv run`
  which would re-sync the drifted clone lock and stall), wait for /health,
  reset counters; run opencode WITHOUT exec so the script regains control to
  run the post-mortem and the silent-exit substitute after the run returns.
- budget-feed.js: opencode plugin that gates on /budget/status in
  tool.execute.before (halt/loop deny — the only place to stop a runaway
  one-shot run; opencode has no PostToolUse-deny) and records the executed
  tool + args-hash in tool.execute.after. Fail-open; bare-verb normalization
  for MCP-namespaced terminal verbs.
- silent-exit substitute: on a graceful exit with no terminal verb the
  entrypoint posts /terminal/force_substitute so the task isn't left stuck
  claimed/in_progress (Stop-hook parity at the boundary).
- opencode_config: wire budget-feed into the plugin array; add
  ROBOCO_OPENCODE_EXTRA_PLUGINS so per-image role tool plugins load scoped to
  one role; read the per-role ROBOCO_GROK_EDIT_PERMISSION.

Targeted gate green (ruff/mypy/xenon + opencode_config tests; node --check on
the plugins; bash -n on the entrypoint).
This commit is contained in:
Renn F
2026-06-18 19:53:22 +02:00
parent e0caccb5dc
commit 82945ae023
5 changed files with 280 additions and 38 deletions
+47 -2
View File
@@ -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: