fix(grok): make the opencode runtime actually load — proven live on grok-build-0.1

Live verification (opencode 1.17.8 + grok-build-0.1, funded key) showed the Grok
runtime was loading INERT, three ways:

1. The provider override `provider.xai.npm=@ai-sdk/openai` failed model
   resolution (ProviderModelNotFoundError) — opencode can't resolve that package
   from its module path. Worse, ANY custom `provider.xai` block (even just
   options) breaks plugin-tool registration. opencode's BUILT-IN xai provider
   drives grok-build-0.1 with working tool-calls, so emit NO provider block; the
   key + base reach it via XAI_API_KEY / XAI_BASE_URL env (provider.options.apiKey
   alone does NOT authenticate).
2. Plugins referenced by absolute path in the config `plugin:` array never
   registered their hooks/tools. opencode 1.17.8 only registers from the plugin
   AUTO-DISCOVERY dir (~/.config/opencode/plugin/). Bake all plugins there.
3. Plugins must use a NAMED export, not `export default`.

Changes:
- opencode_config: no `provider` block, no `plugin` array; drop the dead
  XaiTarget + timeout machinery; build_opencode_config now takes a model string.
- GrokProvider / orchestrator interactive env: inject XAI_API_KEY + XAI_BASE_URL
  (drop the now-unused OPENAI_*).
- secret-scrub / budget-feed / secretary-tools / intake-tools: named exports;
  baked into /home/agent/.config/opencode/plugin/ (drop the EXTRA_PLUGINS env).
- agent-grok* Dockerfiles: plugin dir + agent ownership; drop the unneeded
  @ai-sdk/openai global install.

Verified live end-to-end: grok-build-0.1 calls read_company_state AND
submit_directive through secretary-tools.js and the backend receives both with
the agent token; a tool.execute.before guard fires; built-in tool-calls work.
Targeted gate green (ruff/mypy/xenon + opencode_config/providers/interactive
tests; node --check the plugins).
This commit is contained in:
Renn F
2026-06-18 21:14:06 +02:00
parent f314723c98
commit e29168653a
13 changed files with 150 additions and 251 deletions
+5 -4
View File
@@ -13,10 +13,11 @@ FROM roboco-agent-grok
USER root USER root
# The intake propose_draft tool plugin (the model calls it; the driver turns the # The intake propose_draft tool plugin (the model calls it; the driver turns the
# call into the panel's draft card). Scoped to THIS image via # call into the panel's draft card), baked into the auto-discovery dir so only
# ROBOCO_OPENCODE_EXTRA_PLUGINS so only the intake role carries it. # the intake image carries it. opencode registers tools from this directory, not
COPY docker/grok/intake-tools.js /app/opencode-plugins/intake-tools.js # from a config `plugin:`-array path (verified live).
ENV ROBOCO_OPENCODE_EXTRA_PLUGINS=/app/opencode-plugins/intake-tools.js COPY docker/grok/intake-tools.js /home/agent/.config/opencode/plugin/intake-tools.js
RUN chown agent:agent /home/agent/.config/opencode/plugin/intake-tools.js
USER agent USER agent
+6 -5
View File
@@ -13,11 +13,12 @@ FROM roboco-agent-grok
USER root USER root
# The CEO-authority tool plugin (read_company_state / read_task / submit_directive). # The CEO-authority tool plugin (read_company_state / read_task / submit_directive),
# Scoped to THIS image via ROBOCO_OPENCODE_EXTRA_PLUGINS so only the Secretary # baked into the auto-discovery dir so ONLY the Secretary image carries it (no
# carries CEO authority; opencode_config appends it to the plugin array. # other role gets CEO authority). opencode registers it from this directory; a
COPY docker/grok/secretary-tools.js /app/opencode-plugins/secretary-tools.js # config `plugin:`-array path would not register its tools (verified live).
ENV ROBOCO_OPENCODE_EXTRA_PLUGINS=/app/opencode-plugins/secretary-tools.js COPY docker/grok/secretary-tools.js /home/agent/.config/opencode/plugin/secretary-tools.js
RUN chown agent:agent /home/agent/.config/opencode/plugin/secretary-tools.js
USER agent USER agent
+19 -16
View File
@@ -12,35 +12,38 @@ FROM roboco-agent-base
USER root USER root
# opencode — the OpenAI-protocol agent runtime. grok-build-0.1 is driven via the # opencode — the OpenAI-protocol agent runtime. grok-build-0.1 runs on opencode's
# OpenAI Responses API, so the provider package is @ai-sdk/openai (NOT # BUILT-IN xai provider (no custom provider npm — that breaks model resolution),
# @ai-sdk/openai-compatible, which is chat/completions only and errors with # so only opencode-ai is installed; it resolves the provider SDK at runtime.
# "responses is not a function"). opencode resolves it at runtime, but RUN npm install -g opencode-ai \
# pre-installing keeps first spawn off the network.
RUN npm install -g opencode-ai @ai-sdk/openai \
&& npm cache clean --force \ && npm cache clean --force \
&& rm -rf /root/.npm /tmp/* && rm -rf /root/.npm /tmp/*
# opencode plugins (referenced from the generated opencode.json `plugin:` array): # opencode plugins, baked into the AUTO-DISCOVERY dir (~/.config/opencode/plugin/).
# opencode 1.17.8 does NOT register a plugin's hooks/tools from a config
# `plugin:`-array absolute path — only from this directory (verified live). Each
# plugin uses a NAMED export.
# secret-scrub — bash-guard parity (PAT/credential deny on tool.execute.before) # secret-scrub — bash-guard parity (PAT/credential deny on tool.execute.before)
# budget-feed — POSTs budget/loop/terminal counters to the in-container SDK # budget-feed — POSTs budget/loop/terminal counters to the in-container SDK
# server (tool.execute.{before,after}); the entrypoint starts # server (tool.execute.{before,after}); the entrypoint starts
# that server (roboco.agent_sdk.server) for Claude-parity. # that server (roboco.agent_sdk.server) for Claude-parity.
COPY docker/grok/secret-scrub.js /app/opencode-plugins/secret-scrub.js COPY docker/grok/secret-scrub.js /home/agent/.config/opencode/plugin/secret-scrub.js
COPY docker/grok/budget-feed.js /app/opencode-plugins/budget-feed.js COPY docker/grok/budget-feed.js /home/agent/.config/opencode/plugin/budget-feed.js
# Entrypoint: render opencode.json, then run opencode (overrides base's `claude`). # Entrypoint: render opencode.json, then run opencode (overrides base's `claude`).
COPY docker/scripts/grok-agent-entrypoint.sh /app/scripts/grok-agent-entrypoint.sh COPY docker/scripts/grok-agent-entrypoint.sh /app/scripts/grok-agent-entrypoint.sh
RUN chmod 0755 /app/scripts/grok-agent-entrypoint.sh RUN chmod 0755 /app/scripts/grok-agent-entrypoint.sh
# opencode persists data under ~/.local/share and state under ~/.local/state. # opencode persists data under ~/.local/share and state under ~/.local/state, and
# When the orchestrator bind-mounts the opencode store at # reads config + plugins from ~/.config/opencode. When the orchestrator
# ~/.local/share/opencode, docker creates the intermediate ~/.local AS ROOT, so # bind-mounts the opencode store at ~/.local/share/opencode, docker creates the
# the non-root agent can no longer create its sibling ~/.local/state and opencode # intermediate ~/.local AS ROOT, so the non-root agent can no longer create its
# EACCESes at boot. Pre-create the tree agent-owned so the mount leaves the # siblings and opencode EACCESes at boot. Pre-create the trees agent-owned so the
# parents writable (complements the orchestrator's 0777 host-source pre-create). # mount leaves the parents writable (complements the orchestrator's 0777
# host-source pre-create), and so the baked plugin dir is agent-owned.
RUN mkdir -p /home/agent/.local/share/opencode /home/agent/.local/state \ RUN mkdir -p /home/agent/.local/share/opencode /home/agent/.local/state \
&& chown -R agent:agent /home/agent/.local /home/agent/.config/opencode/plugin \
&& chown -R agent:agent /home/agent/.local /home/agent/.config
USER agent USER agent
+4 -1
View File
@@ -77,7 +77,10 @@ function bareVerb(tool) {
return tool; return tool;
} }
export default async () => { // Named export + loaded from the plugin auto-discovery dir
// (~/.config/opencode/plugin/) — opencode 1.17.8 ignores config `plugin:`-array
// absolute paths for hook/tool registration (verified live).
export const RobocoBudgetFeed = async () => {
return { return {
"tool.execute.before": async (input) => { "tool.execute.before": async (input) => {
const status = await sdk("GET", "/budget/status", null); const status = await sdk("GET", "/budget/status", null);
+5 -1
View File
@@ -20,7 +20,11 @@
import { tool } from "@opencode-ai/plugin"; import { tool } from "@opencode-ai/plugin";
export default async () => ({ // Named export + loaded from the plugin auto-discovery dir
// (~/.config/opencode/plugin/) — opencode 1.17.8 only registers Hooks.tool from
// directory auto-discovery, not a config `plugin:`-array absolute path
// (verified live).
export const RobocoIntakeTools = async () => ({
tool: { tool: {
propose_draft: tool({ propose_draft: tool({
description: description:
+5 -1
View File
@@ -164,7 +164,11 @@ function denyBash(command) {
return null; return null;
} }
export default async () => { // Named export + loaded from opencode's plugin auto-discovery dir
// (~/.config/opencode/plugin/). opencode 1.17.8 does NOT register a plugin's
// hooks/tools when it's listed by absolute path in the config `plugin:` array —
// only directory auto-discovery works (verified live against grok-build-0.1).
export const RobocoSecretScrub = async () => {
return { return {
"tool.execute.before": async (input, output) => { "tool.execute.before": async (input, output) => {
const tool = input?.tool; const tool = input?.tool;
+6 -1
View File
@@ -66,7 +66,12 @@ async function callBackend(method, path, body) {
const asText = (data) => JSON.stringify(data); const asText = (data) => JSON.stringify(data);
export default async () => ({ // Named export + loaded from the plugin auto-discovery dir
// (~/.config/opencode/plugin/) — opencode 1.17.8 does NOT register tools from a
// config `plugin:`-array absolute path; only directory auto-discovery + a named
// export registers Hooks.tool (verified live: the model called the tool and the
// backend received the request).
export const RobocoSecretaryTools = async () => ({
tool: { tool: {
read_company_state: tool({ read_company_state: tool({
description: description:
+6 -3
View File
@@ -260,11 +260,14 @@ class GrokProvider(AgentProvider):
base_url = config.provider_base_url or _DEFAULT_XAI_BASE_URL base_url = config.provider_base_url or _DEFAULT_XAI_BASE_URL
cmd.extend( cmd.extend(
[ [
# OpenAI-compatible client config (standard env the CLI reads). # opencode's BUILT-IN xai provider authenticates from XAI_API_KEY
# and reads XAI_BASE_URL for the endpoint — opencode_config emits
# no provider block (any provider.xai block breaks plugin-tool
# registration), so these envs are the only LLM wiring.
"-e", "-e",
f"OPENAI_BASE_URL={base_url}", f"XAI_API_KEY={config.provider_auth_token}",
"-e", "-e",
f"OPENAI_API_KEY={config.provider_auth_token}", f"XAI_BASE_URL={base_url}",
# Operational inputs for the grok image entrypoint. # Operational inputs for the grok image entrypoint.
"-e", "-e",
f"ROBOCO_AGENT_MODEL={config.model}", f"ROBOCO_AGENT_MODEL={config.model}",
+56 -111
View File
@@ -7,18 +7,30 @@ sets (``OPENAI_*`` + ``ROBOCO_*``) plus the mounted Claude Code
as importable Python (not a shell heredoc) makes the translation unit-testable. as importable Python (not a shell heredoc) makes the translation unit-testable.
Config shape per opencode docs (https://opencode.ai/docs/config): Config shape per opencode docs (https://opencode.ai/docs/config):
* ``provider.<id>`` — ``@ai-sdk/openai`` (the Responses API; see ``_PROVIDER_NPM``) * NO ``provider`` block. opencode's BUILT-IN xai provider drives
with ``options.baseURL`` / ``options.apiKey`` / ``options.timeout`` / grok-build-0.1; ANY custom ``provider.xai`` block (even just ``options``)
``options.chunkTimeout``; ``model`` selects ``<id>/<model>``. breaks plugin-tool registration, and a ``npm`` override additionally breaks
model resolution (ProviderModelNotFoundError) — all verified live on opencode
1.17.8. The key + base URL reach the provider via the ``XAI_API_KEY`` /
``XAI_BASE_URL`` env vars; ``model`` selects ``xai/<model>``.
* ``mcp.<name>`` — ``{type:"local", command:[...], environment:{...}}``; this * ``mcp.<name>`` — ``{type:"local", command:[...], environment:{...}}``; this
is where RoboCo's gateway servers (roboco-flow / roboco-do / ...) are wired, is where RoboCo's gateway servers (roboco-flow / roboco-do / ...) are wired,
translated from Claude Code's ``mcpServers`` (``command`` + ``args`` + ``env``). translated from Claude Code's ``mcpServers`` (``command`` + ``args`` + ``env``).
* ``permission.{bash,edit}`` and ``instructions`` (system prompt + briefing). * ``permission.{bash,edit,external_directory}`` and ``instructions`` (system
prompt + briefing).
* ``tools`` — opencode's subagent ``task`` tool is hard-disabled. No RoboCo role * ``tools`` — opencode's subagent ``task`` tool is hard-disabled. No RoboCo role
uses opencode-internal subagents (work is driven through the gateway verbs), uses opencode-internal subagents (work is driven through the gateway verbs),
and a ``task``-spawned subagent on ``grok-build-0.1`` whose model call opens an and a ``task``-spawned subagent on ``grok-build-0.1`` whose model call opens an
idle stream hangs the parent run with no recovery (observed live on a PR idle stream hangs the parent run with no recovery (observed live on a PR
review). The request/stream timeouts below are the defence-in-depth backstop. review). This is the primary idle-stream defence (the orchestrator reaper is
the backstop).
There is NO ``plugin`` key: opencode 1.17.8 does not register a plugin's
hooks/tools from a config ``plugin:``-array absolute path — only from the plugin
AUTO-DISCOVERY dir (``~/.config/opencode/plugin/``). The plugins are baked there
in the images instead (secret-scrub + budget-feed in the base grok image; the
Secretary's directive tools and the Intake's propose_draft in their interactive
images).
GUARDRAIL PARITY: the bash-guard (PAT-scrub) is ported via ``secret-scrub.js`` GUARDRAIL PARITY: the bash-guard (PAT-scrub) is ported via ``secret-scrub.js``
(``tool.execute.before``); the per-session budget / loop / terminal-verb (``tool.execute.before``); the per-session budget / loop / terminal-verb
@@ -33,11 +45,6 @@ post-mortem + Stop silent-exit substitute run at the entrypoint boundary after
``opencode run`` returns. ``bash`` / ``edit`` permissions are scoped per role ``opencode run`` returns. ``bash`` / ``edit`` permissions are scoped per role
(read-only roles get ``edit=deny``; only delivery roles get ``bash``) and stay (read-only roles get ``edit=deny``; only delivery roles get ``bash``) and stay
operator-tunable (``ROBOCO_GROK_BASH_PERMISSION`` / ``ROBOCO_GROK_EDIT_PERMISSION``). 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 from __future__ import annotations
@@ -50,74 +57,30 @@ from typing import Any
_OPENCODE_SCHEMA = "https://opencode.ai/config.json" _OPENCODE_SCHEMA = "https://opencode.ai/config.json"
_PROVIDER_ID = "xai" _PROVIDER_ID = "xai"
# grok-build-0.1 is driven through the OpenAI **Responses** API (opencode calls # We do NOT override provider.<id>.npm. opencode's BUILT-IN xai provider already
# model.responses()). Only @ai-sdk/openai implements that — @ai-sdk/openai-compatible # drives grok-build-0.1 with working tool-calls (verified live); a custom `npm`
# is chat/completions only and errors with "responses is not a function". # (e.g. @ai-sdk/openai) is not resolvable from opencode's module path and makes
# Confirmed via a live opencode run against api.x.ai/v1. # the model fail to resolve (ProviderModelNotFoundError). The xAI key is injected
_PROVIDER_NPM = "@ai-sdk/openai" # via the XAI_API_KEY env var the built-in provider reads (set by GrokProvider /
# the orchestrator) — provider.options.apiKey alone does NOT authenticate it.
# 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 (secret-scrub / budget-feed / the per-role tool plugins) are NOT listed
# budget-feed POSTs the budget/loop/terminal counters to the in-container SDK # in the config `plugin:` array — opencode 1.17.8 does not register a plugin's
# server (tool.execute.{before,after}). Per-image extras (secretary / intake # hooks/tools when it is referenced by absolute path there. They are baked into
# tools) are appended from ROBOCO_OPENCODE_EXTRA_PLUGINS (see _extra_plugins). # the plugin AUTO-DISCOVERY dir (~/.config/opencode/plugin/, i.e.
_PLUGINS = [ # /home/agent/.config/opencode/plugin/ in the image) instead, which registers
"/app/opencode-plugins/secret-scrub.js", # both tools and hooks (verified live against grok-build-0.1).
"/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 # 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 # config (see the module docstring): a RoboCo agent never spawns opencode's own
# subagents, and one that does can wedge the parent run on an idle stream. # subagents, and one that does can wedge the parent run on an idle stream. This
# is the primary defence against the idle-stream hang; the orchestrator's
# reaper watchdog (_maybe_kill_wedged_grok) is the backstop. (Per-provider
# request/stream timeouts can't be set without a custom provider.npm, which
# breaks model resolution — see the module docstring — so they are not used.)
_SUBAGENT_TOOL = "task" _SUBAGENT_TOOL = "task"
# Request / stream timeouts (ms) written into ``provider.xai.options``. ``timeout``
# bounds a single model call; ``chunkTimeout`` aborts a stream that goes idle for
# this long (no chunk arrives) — the backstop for the idle-SSE hang. Both are
# operator-tunable via env (see ``main``).
_DEFAULT_REQUEST_TIMEOUT_MS = 300_000
_DEFAULT_CHUNK_TIMEOUT_MS = 120_000
def _env_int(name: str, default: int) -> int:
"""Read a positive int from env ``name``; fall back to ``default``.
A missing, blank, non-integer, or non-positive value yields ``default`` so a
typo in an operator override can never disable the timeout entirely.
"""
raw = os.environ.get(name, "").strip()
if not raw:
return default
try:
value = int(raw)
except ValueError:
return default
return value if value > 0 else default
@dataclass(frozen=True)
class XaiTarget:
"""The xAI endpoint a Grok agent talks to."""
base_url: str
api_key: str
model: str
@dataclass(frozen=True) @dataclass(frozen=True)
class OpencodeGuards: class OpencodeGuards:
@@ -127,15 +90,13 @@ class OpencodeGuards:
reading paths outside the project cwd (opencode auto-DENIES an ``ask`` in reading paths outside the project cwd (opencode auto-DENIES an ``ask`` in
headless mode, which blocked the pr-reviewer from reading a diff it wrote to headless mode, which blocked the pr-reviewer from reading a diff it wrote to
/tmp — so default ``allow``: the container is the sandbox and secret-scrub /tmp — so default ``allow``: the container is the sandbox and secret-scrub
still blocks credential files); the timeouts bound a single model call and still blocks credential files); ``disable_subagents`` removes the subagent
abort an idle stream; ``disable_subagents`` removes the subagent ``task`` tool. ``task`` tool.
""" """
bash_permission: str = "allow" bash_permission: str = "allow"
edit_permission: str = "allow" edit_permission: str = "allow"
external_directory_permission: str = "allow" external_directory_permission: str = "allow"
request_timeout_ms: int = _DEFAULT_REQUEST_TIMEOUT_MS
chunk_timeout_ms: int = _DEFAULT_CHUNK_TIMEOUT_MS
disable_subagents: bool = True disable_subagents: bool = True
@@ -166,31 +127,25 @@ def translate_mcp_servers(mcp_config: dict[str, Any]) -> dict[str, Any]:
def build_opencode_config( def build_opencode_config(
mcp_config: dict[str, Any], mcp_config: dict[str, Any],
target: XaiTarget, model: str,
*, *,
instruction_paths: list[str], instruction_paths: list[str],
guards: OpencodeGuards | None = None, guards: OpencodeGuards | None = None,
extra_plugins: list[str] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Build the full ``opencode.json`` dict for a Grok agent.""" """Build the ``opencode.json`` dict for a Grok agent.
Emits NO ``provider`` block: opencode's BUILT-IN xai provider drives
grok-build-0.1, and ANY custom ``provider.xai`` block breaks plugin-tool
registration AND (without ``XAI_API_KEY``) model resolution — all verified
live on opencode 1.17.8. The key + base URL are injected via the
``XAI_API_KEY`` / ``XAI_BASE_URL`` env vars (set by GrokProvider / the
orchestrator). No ``plugin`` array either — plugins live in the
auto-discovery dir baked into the images.
"""
guards = guards or OpencodeGuards() guards = guards or OpencodeGuards()
plugins = [*_PLUGINS, *(extra_plugins or [])]
config: dict[str, Any] = { config: dict[str, Any] = {
"$schema": _OPENCODE_SCHEMA, "$schema": _OPENCODE_SCHEMA,
"provider": { "model": f"{_PROVIDER_ID}/{model}",
_PROVIDER_ID: {
"npm": _PROVIDER_NPM,
"name": "xAI",
"options": {
"baseURL": target.base_url,
"apiKey": target.api_key,
"timeout": guards.request_timeout_ms,
"chunkTimeout": guards.chunk_timeout_ms,
},
"models": {target.model: {"name": target.model}},
}
},
"model": f"{_PROVIDER_ID}/{target.model}",
"mcp": translate_mcp_servers(mcp_config), "mcp": translate_mcp_servers(mcp_config),
"permission": { "permission": {
"bash": guards.bash_permission, "bash": guards.bash_permission,
@@ -202,9 +157,6 @@ def build_opencode_config(
"external_directory": guards.external_directory_permission, "external_directory": guards.external_directory_permission,
}, },
"instructions": instruction_paths, "instructions": instruction_paths,
# 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: if guards.disable_subagents:
# Remove the subagent tool entirely so the model can never invoke it. # Remove the subagent tool entirely so the model can never invoke it.
@@ -223,12 +175,12 @@ def _load_mcp_config(path: str) -> dict[str, Any]:
def main() -> int: def main() -> int:
"""Entrypoint: read env + mounted mcp-config.json, write opencode.json.""" """Entrypoint: read env + mounted mcp-config.json, write opencode.json.
target = XaiTarget(
base_url=os.environ.get("OPENAI_BASE_URL", "https://api.x.ai/v1"), The xAI key + base URL are NOT read here — they reach opencode's built-in
api_key=os.environ.get("OPENAI_API_KEY", ""), xai provider via the ``XAI_API_KEY`` / ``XAI_BASE_URL`` env vars.
model=os.environ.get("ROBOCO_AGENT_MODEL", "grok-build-0.1"), """
) model = os.environ.get("ROBOCO_AGENT_MODEL", "grok-build-0.1")
mcp_path = os.environ.get("ROBOCO_MCP_CONFIG", "/app/mcp-config.json") mcp_path = os.environ.get("ROBOCO_MCP_CONFIG", "/app/mcp-config.json")
system_prompt = os.environ.get("ROBOCO_SYSTEM_PROMPT", "/app/system-prompt.md") system_prompt = os.environ.get("ROBOCO_SYSTEM_PROMPT", "/app/system-prompt.md")
# Default to opencode's global config location so it is found regardless of # Default to opencode's global config location so it is found regardless of
@@ -243,12 +195,6 @@ def main() -> int:
external_directory_permission=os.environ.get( external_directory_permission=os.environ.get(
"ROBOCO_GROK_EXTERNAL_DIR_PERMISSION", "allow" "ROBOCO_GROK_EXTERNAL_DIR_PERMISSION", "allow"
), ),
request_timeout_ms=_env_int(
"ROBOCO_GROK_REQUEST_TIMEOUT_MS", _DEFAULT_REQUEST_TIMEOUT_MS
),
chunk_timeout_ms=_env_int(
"ROBOCO_GROK_CHUNK_TIMEOUT_MS", _DEFAULT_CHUNK_TIMEOUT_MS
),
) )
# Instructions = system prompt + the SessionStart briefing when mounted. # Instructions = system prompt + the SessionStart briefing when mounted.
@@ -257,10 +203,9 @@ def main() -> int:
config = build_opencode_config( config = build_opencode_config(
_load_mcp_config(mcp_path), _load_mcp_config(mcp_path),
target, model,
instruction_paths=instructions, instruction_paths=instructions,
guards=guards, guards=guards,
extra_plugins=_extra_plugins(),
) )
out = Path(out_path) out = Path(out_path)
out.parent.mkdir(parents=True, exist_ok=True) out.parent.mkdir(parents=True, exist_ok=True)
+5 -2
View File
@@ -3442,10 +3442,13 @@ class AgentOrchestrator:
cmd.extend(["-v", f"{opencode_host}:/home/agent/.local/share/opencode"]) cmd.extend(["-v", f"{opencode_host}:/home/agent/.local/share/opencode"])
cmd.extend( cmd.extend(
[ [
# Built-in xai provider authenticates from XAI_API_KEY and
# reads XAI_BASE_URL; opencode_config emits no provider block
# (any provider.xai block breaks plugin-tool registration).
"-e", "-e",
f"OPENAI_BASE_URL={base_url or 'https://api.x.ai/v1'}", f"XAI_API_KEY={auth_token or ''}",
"-e", "-e",
f"OPENAI_API_KEY={auth_token or ''}", f"XAI_BASE_URL={base_url or 'https://api.x.ai/v1'}",
"-e", "-e",
f"ROBOCO_AGENT_MODEL={spec.model}", f"ROBOCO_AGENT_MODEL={spec.model}",
"-e", "-e",
+20 -95
View File
@@ -2,23 +2,13 @@
from __future__ import annotations from __future__ import annotations
import os
from unittest.mock import patch
from roboco.llm.providers.opencode_config import ( from roboco.llm.providers.opencode_config import (
_DEFAULT_CHUNK_TIMEOUT_MS,
_DEFAULT_REQUEST_TIMEOUT_MS,
OpencodeGuards, OpencodeGuards,
XaiTarget,
_env_int,
_extra_plugins,
build_opencode_config, build_opencode_config,
translate_mcp_servers, translate_mcp_servers,
) )
_TARGET = XaiTarget( _MODEL = "grok-build-0.1"
base_url="https://api.x.ai/v1", api_key="xai-key", model="grok-build-0.1"
)
_MCP = { _MCP = {
"mcpServers": { "mcpServers": {
@@ -72,57 +62,28 @@ def test_translate_mcp_servers_omits_environment_when_no_env() -> None:
assert out["x"]["command"] == ["uv", "run"] assert out["x"]["command"] == ["uv", "run"]
def test_build_opencode_config_provider_and_model() -> None: def test_build_opencode_config_emits_no_provider_block() -> None:
cfg = build_opencode_config( cfg = build_opencode_config(
_MCP, _MCP,
_TARGET, _MODEL,
instruction_paths=["/app/system-prompt.md"], instruction_paths=["/app/system-prompt.md"],
) )
provider = cfg["provider"]["xai"] # CRITICAL: NO provider block. ANY provider.xai block breaks plugin-tool
# grok-build-0.1 needs the Responses API → @ai-sdk/openai, not -compatible. # registration on opencode 1.17.8 (verified live). The built-in xai provider
assert provider["npm"] == "@ai-sdk/openai" # drives the model; the key reaches it via the XAI_API_KEY env var.
assert provider["options"]["baseURL"] == "https://api.x.ai/v1" assert "provider" not in cfg
assert provider["options"]["apiKey"] == "xai-key"
assert "grok-build-0.1" in provider["models"]
# Top-level model selector is "<provider>/<model>". # Top-level model selector is "<provider>/<model>".
assert cfg["model"] == "xai/grok-build-0.1" assert cfg["model"] == "xai/grok-build-0.1"
# Gateway servers carried through. # Gateway servers carried through.
assert "roboco-flow" in cfg["mcp"] assert "roboco-flow" in cfg["mcp"]
assert cfg["instructions"] == ["/app/system-prompt.md"] assert cfg["instructions"] == ["/app/system-prompt.md"]
# 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: def test_build_opencode_config_has_no_plugin_array() -> None:
# Per-image role tool plugins (secretary directive tools, intake propose_draft) # opencode 1.17.8 ignores config `plugin:`-array absolute paths for
# append AFTER the baked defaults so the role-scoped tools load too. # registration; plugins live in the auto-discovery dir, baked into the images.
cfg = build_opencode_config( cfg = build_opencode_config(_MCP, _MODEL, instruction_paths=[])
_MCP, assert "plugin" not in cfg
_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: def test_build_opencode_config_edit_permission_is_tunable() -> None:
@@ -130,7 +91,7 @@ def test_build_opencode_config_edit_permission_is_tunable() -> None:
# a Grok agent can't write code on a role that must never touch the tree. # a Grok agent can't write code on a role that must never touch the tree.
cfg = build_opencode_config( cfg = build_opencode_config(
{}, {},
_TARGET, _MODEL,
instruction_paths=[], instruction_paths=[],
guards=OpencodeGuards(edit_permission="deny"), guards=OpencodeGuards(edit_permission="deny"),
) )
@@ -140,7 +101,7 @@ def test_build_opencode_config_edit_permission_is_tunable() -> None:
def test_build_opencode_config_bash_permission_is_tunable() -> None: def test_build_opencode_config_bash_permission_is_tunable() -> None:
cfg = build_opencode_config( cfg = build_opencode_config(
{}, {},
_TARGET, _MODEL,
instruction_paths=[], instruction_paths=[],
guards=OpencodeGuards(bash_permission="deny"), guards=OpencodeGuards(bash_permission="deny"),
) )
@@ -151,68 +112,32 @@ def test_build_opencode_config_bash_permission_is_tunable() -> None:
def test_build_opencode_config_allows_external_directory_by_default() -> None: def test_build_opencode_config_allows_external_directory_by_default() -> None:
# opencode auto-denies an "ask" external-dir read in headless mode (the # opencode auto-denies an "ask" external-dir read in headless mode (the
# pr-reviewer couldn't read a diff it wrote to /tmp); default "allow". # pr-reviewer couldn't read a diff it wrote to /tmp); default "allow".
cfg = build_opencode_config(_MCP, _TARGET, instruction_paths=[]) cfg = build_opencode_config(_MCP, _MODEL, instruction_paths=[])
assert cfg["permission"]["external_directory"] == "allow" assert cfg["permission"]["external_directory"] == "allow"
def test_build_opencode_config_external_directory_is_tunable() -> None: def test_build_opencode_config_external_directory_is_tunable() -> None:
cfg = build_opencode_config( cfg = build_opencode_config(
{}, {},
_TARGET, _MODEL,
instruction_paths=[], instruction_paths=[],
guards=OpencodeGuards(external_directory_permission="ask"), guards=OpencodeGuards(external_directory_permission="deny"),
) )
assert cfg["permission"]["external_directory"] == "ask" assert cfg["permission"]["external_directory"] == "deny"
def test_build_opencode_config_disables_subagent_task_tool_by_default() -> None: def test_build_opencode_config_disables_subagent_task_tool_by_default() -> None:
# The subagent `task` tool must be hard-disabled: a RoboCo role never uses # The subagent `task` tool must be hard-disabled: a RoboCo role never uses
# opencode-internal subagents, and one spawned on grok-build-0.1 hung the run. # opencode-internal subagents, and one spawned on grok-build-0.1 hung the run.
cfg = build_opencode_config(_MCP, _TARGET, instruction_paths=[]) cfg = build_opencode_config(_MCP, _MODEL, instruction_paths=[])
assert cfg["tools"] == {"task": False} assert cfg["tools"] == {"task": False}
def test_build_opencode_config_subagents_can_be_re_enabled() -> None: def test_build_opencode_config_subagents_can_be_re_enabled() -> None:
cfg = build_opencode_config( cfg = build_opencode_config(
_MCP, _MCP,
_TARGET, _MODEL,
instruction_paths=[], instruction_paths=[],
guards=OpencodeGuards(disable_subagents=False), guards=OpencodeGuards(disable_subagents=False),
) )
assert "tools" not in cfg assert "tools" not in cfg
def test_build_opencode_config_sets_default_timeouts() -> None:
# Both timeouts land under provider.<id>.options so opencode aborts a stalled
# request / idle stream instead of hanging the parent run forever.
opts = build_opencode_config(_MCP, _TARGET, instruction_paths=[])["provider"][
"xai"
]["options"]
assert opts["timeout"] == _DEFAULT_REQUEST_TIMEOUT_MS
assert opts["chunkTimeout"] == _DEFAULT_CHUNK_TIMEOUT_MS
def test_build_opencode_config_timeouts_are_tunable() -> None:
req_ms, chunk_ms = 111_000, 22_000
opts = build_opencode_config(
_MCP,
_TARGET,
instruction_paths=[],
guards=OpencodeGuards(request_timeout_ms=req_ms, chunk_timeout_ms=chunk_ms),
)["provider"]["xai"]["options"]
assert opts["timeout"] == req_ms
assert opts["chunkTimeout"] == chunk_ms
def test_env_int_parses_and_falls_back() -> None:
fallback = 999
parsed = 45_000
with patch.dict(os.environ, {"X_MS": str(parsed)}):
assert _env_int("X_MS", fallback) == parsed
# Missing, blank, non-integer, and non-positive all fall back to the default
# so a bad operator override can never disable the timeout entirely.
with patch.dict(os.environ, {}, clear=True):
assert _env_int("X_MS", fallback) == fallback
for bad in ("", " ", "abc", "0", "-5"):
with patch.dict(os.environ, {"X_MS": bad}):
assert _env_int("X_MS", fallback) == fallback
+7 -5
View File
@@ -5,7 +5,7 @@ GrokProvider (xAI / OpenAI protocol) — especially the safety properties an
OpenAI-protocol agent provider must hold: OpenAI-protocol agent provider must hold:
* the agent gets the MCP gateway wiring (reuses the orchestrator mount path); * the agent gets the MCP gateway wiring (reuses the orchestrator mount path);
* the xAI endpoint is injected as OPENAI_* and never mislabelled ANTHROPIC_*; * the xAI endpoint is injected as XAI_* and never mislabelled ANTHROPIC_*;
* the prompt travels via env, so a leading ``--`` cannot become a CLI flag. * the prompt travels via env, so a leading ``--`` cannot become a CLI flag.
""" """
@@ -177,7 +177,7 @@ async def test_grok_spawn_requires_mcp_config() -> None:
await provider.spawn(_config(mcp_config_path=None)) await provider.spawn(_config(mcp_config_path=None))
async def test_grok_spawn_injects_openai_env_and_no_anthropic_leak() -> None: async def test_grok_spawn_injects_xai_env_and_no_anthropic_leak() -> None:
host = _FakeHost() host = _FakeHost()
provider = GrokProvider(host, image="roboco-agent-grok:test") provider = GrokProvider(host, image="roboco-agent-grok:test")
with patch( with patch(
@@ -185,8 +185,10 @@ async def test_grok_spawn_injects_openai_env_and_no_anthropic_leak() -> None:
) as exec_mock: ) as exec_mock:
await provider.spawn(_config(), initial_prompt="do the work") await provider.spawn(_config(), initial_prompt="do the work")
cmd = list(exec_mock.call_args.args) cmd = list(exec_mock.call_args.args)
assert "OPENAI_BASE_URL=https://api.x.ai/v1" in cmd # opencode's built-in xai provider reads XAI_API_KEY / XAI_BASE_URL (no
assert "OPENAI_API_KEY=xai-secret-key" in cmd # provider block in the rendered config — that breaks plugin-tool reg).
assert "XAI_API_KEY=xai-secret-key" in cmd
assert "XAI_BASE_URL=https://api.x.ai/v1" in cmd
# The xAI endpoint must NOT be injected as an Anthropic var. # The xAI endpoint must NOT be injected as an Anthropic var.
assert not any(c.startswith("ANTHROPIC_BASE_URL=") for c in cmd) assert not any(c.startswith("ANTHROPIC_BASE_URL=") for c in cmd)
assert not any(c.startswith("ANTHROPIC_AUTH_TOKEN=") for c in cmd) assert not any(c.startswith("ANTHROPIC_AUTH_TOKEN=") for c in cmd)
@@ -245,7 +247,7 @@ async def test_grok_spawn_defaults_base_url_when_route_blank() -> None:
) as exec_mock: ) as exec_mock:
await provider.spawn(_config(provider_base_url=None)) await provider.spawn(_config(provider_base_url=None))
cmd = list(exec_mock.call_args.args) cmd = list(exec_mock.call_args.args)
assert "OPENAI_BASE_URL=https://api.x.ai/v1" in cmd assert "XAI_BASE_URL=https://api.x.ai/v1" in cmd
async def test_grok_spawn_raises_on_docker_failure() -> None: async def test_grok_spawn_raises_on_docker_failure() -> None:
@@ -1,7 +1,7 @@
"""Interactive intake/secretary builders fork a GROK route onto opencode. """Interactive intake/secretary builders fork a GROK route onto opencode.
A GROK route swaps the Claude SDK-driver image for the opencode-serve image and A GROK route swaps the Claude SDK-driver image for the opencode-serve image and
the ANTHROPIC_* env for OPENAI_* + the opencode store mount; every other the ANTHROPIC_* env for XAI_* + the opencode store mount; every other
provider keeps the Claude path's ANTHROPIC_* behaviour. provider keeps the Claude path's ANTHROPIC_* behaviour.
""" """
@@ -48,7 +48,7 @@ def _intake_spec(
) )
def test_intake_grok_uses_openai_env_and_opencode_mount() -> None: def test_intake_grok_uses_xai_env_and_opencode_mount() -> None:
cmd = AgentOrchestrator._build_intake_run_cmd( cmd = AgentOrchestrator._build_intake_run_cmd(
_intake_spec( _intake_spec(
"grok", "grok",
@@ -57,8 +57,8 @@ def test_intake_grok_uses_openai_env_and_opencode_mount() -> None:
grok_variant="minimal", grok_variant="minimal",
) )
) )
assert "OPENAI_BASE_URL=https://api.x.ai/v1" in cmd assert "XAI_API_KEY=xai-key" in cmd
assert "OPENAI_API_KEY=xai-key" in cmd assert "XAI_BASE_URL=https://api.x.ai/v1" in cmd
assert "ROBOCO_AGENT_MODEL=grok-build-0.1" in cmd assert "ROBOCO_AGENT_MODEL=grok-build-0.1" in cmd
assert "ROBOCO_SYSTEM_PROMPT=/app/system-prompt.md" in cmd assert "ROBOCO_SYSTEM_PROMPT=/app/system-prompt.md" in cmd
assert "/h/oc/intake-1:/home/agent/.local/share/opencode" in cmd assert "/h/oc/intake-1:/home/agent/.local/share/opencode" in cmd
@@ -97,7 +97,7 @@ def test_intake_anthropic_keeps_anthropic_env() -> None:
) )
assert "ANTHROPIC_BASE_URL=https://api.anthropic.com" in cmd assert "ANTHROPIC_BASE_URL=https://api.anthropic.com" in cmd
assert "ANTHROPIC_AUTH_TOKEN=sk-ant" in cmd assert "ANTHROPIC_AUTH_TOKEN=sk-ant" in cmd
assert not any(c.startswith("OPENAI_") for c in cmd) assert not any(c.startswith("XAI_") for c in cmd)
assert cmd[-1] == "roboco-agent-prompter" assert cmd[-1] == "roboco-agent-prompter"
@@ -118,7 +118,7 @@ def test_secretary_grok_uses_openai_env_and_grok_image() -> None:
model="grok-build-0.1", model="grok-build-0.1",
) )
cmd = AgentOrchestrator._build_secretary_run_cmd(spec) cmd = AgentOrchestrator._build_secretary_run_cmd(spec)
assert "OPENAI_API_KEY=xai-key" in cmd assert "XAI_API_KEY=xai-key" in cmd
assert "/h/oc/sec-1:/home/agent/.local/share/opencode" in cmd assert "/h/oc/sec-1:/home/agent/.local/share/opencode" in cmd
# The HMAC identity the directive tools authenticate with survives. # The HMAC identity the directive tools authenticate with survives.
assert "ROBOCO_AGENT_TOKEN=hmac-secretary" in cmd assert "ROBOCO_AGENT_TOKEN=hmac-secretary" in cmd