From e29168653a7b94006cd1cf73defebfba6db0ce6a Mon Sep 17 00:00:00 2001 From: Renn F Date: Thu, 18 Jun 2026 21:14:06 +0200 Subject: [PATCH] =?UTF-8?q?fix(grok):=20make=20the=20opencode=20runtime=20?= =?UTF-8?q?actually=20load=20=E2=80=94=20proven=20live=20on=20grok-build-0?= =?UTF-8?q?.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- docker/agent-grok-prompter.Dockerfile | 9 +- docker/agent-grok-secretary.Dockerfile | 11 +- docker/agent-grok.Dockerfile | 35 ++-- docker/grok/budget-feed.js | 5 +- docker/grok/intake-tools.js | 6 +- docker/grok/secret-scrub.js | 6 +- docker/grok/secretary-tools.js | 7 +- roboco/llm/providers/grok.py | 9 +- roboco/llm/providers/opencode_config.py | 167 ++++++------------ roboco/runtime/orchestrator.py | 7 +- tests/unit/llm/test_opencode_config.py | 115 +++--------- tests/unit/llm/test_providers.py | 12 +- .../runtime/test_interactive_grok_spawn.py | 12 +- 13 files changed, 150 insertions(+), 251 deletions(-) diff --git a/docker/agent-grok-prompter.Dockerfile b/docker/agent-grok-prompter.Dockerfile index d6ac8a80..4b5e3749 100644 --- a/docker/agent-grok-prompter.Dockerfile +++ b/docker/agent-grok-prompter.Dockerfile @@ -13,10 +13,11 @@ FROM roboco-agent-grok USER root # 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 -# ROBOCO_OPENCODE_EXTRA_PLUGINS so only the intake role carries it. -COPY docker/grok/intake-tools.js /app/opencode-plugins/intake-tools.js -ENV ROBOCO_OPENCODE_EXTRA_PLUGINS=/app/opencode-plugins/intake-tools.js +# call into the panel's draft card), baked into the auto-discovery dir so only +# the intake image carries it. opencode registers tools from this directory, not +# from a config `plugin:`-array path (verified live). +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 diff --git a/docker/agent-grok-secretary.Dockerfile b/docker/agent-grok-secretary.Dockerfile index 447c64c8..d6e9bd4e 100644 --- a/docker/agent-grok-secretary.Dockerfile +++ b/docker/agent-grok-secretary.Dockerfile @@ -13,11 +13,12 @@ FROM roboco-agent-grok USER root -# 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 -# carries CEO authority; opencode_config appends it to the plugin array. -COPY docker/grok/secretary-tools.js /app/opencode-plugins/secretary-tools.js -ENV ROBOCO_OPENCODE_EXTRA_PLUGINS=/app/opencode-plugins/secretary-tools.js +# The CEO-authority tool plugin (read_company_state / read_task / submit_directive), +# baked into the auto-discovery dir so ONLY the Secretary image carries it (no +# other role gets CEO authority). opencode registers it from this directory; a +# config `plugin:`-array path would not register its tools (verified live). +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 diff --git a/docker/agent-grok.Dockerfile b/docker/agent-grok.Dockerfile index 8ad9be6e..26cbc185 100644 --- a/docker/agent-grok.Dockerfile +++ b/docker/agent-grok.Dockerfile @@ -12,35 +12,38 @@ FROM roboco-agent-base USER root -# opencode — the OpenAI-protocol agent runtime. grok-build-0.1 is driven via the -# OpenAI Responses API, so the provider package is @ai-sdk/openai (NOT -# @ai-sdk/openai-compatible, which is chat/completions only and errors with -# "responses is not a function"). opencode resolves it at runtime, but -# pre-installing keeps first spawn off the network. -RUN npm install -g opencode-ai @ai-sdk/openai \ +# opencode — the OpenAI-protocol agent runtime. grok-build-0.1 runs on opencode's +# BUILT-IN xai provider (no custom provider npm — that breaks model resolution), +# so only opencode-ai is installed; it resolves the provider SDK at runtime. +RUN npm install -g opencode-ai \ && npm cache clean --force \ && 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) # 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 +COPY docker/grok/secret-scrub.js /home/agent/.config/opencode/plugin/secret-scrub.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`). COPY docker/scripts/grok-agent-entrypoint.sh /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. -# When the orchestrator bind-mounts the opencode store at -# ~/.local/share/opencode, docker creates the intermediate ~/.local AS ROOT, so -# the non-root agent can no longer create its sibling ~/.local/state and opencode -# EACCESes at boot. Pre-create the tree agent-owned so the mount leaves the -# parents writable (complements the orchestrator's 0777 host-source pre-create). +# opencode persists data under ~/.local/share and state under ~/.local/state, and +# reads config + plugins from ~/.config/opencode. When the orchestrator +# bind-mounts the opencode store at ~/.local/share/opencode, docker creates the +# intermediate ~/.local AS ROOT, so the non-root agent can no longer create its +# siblings and opencode EACCESes at boot. Pre-create the trees agent-owned so the +# 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 \ - && chown -R agent:agent /home/agent/.local + /home/agent/.config/opencode/plugin \ + && chown -R agent:agent /home/agent/.local /home/agent/.config USER agent diff --git a/docker/grok/budget-feed.js b/docker/grok/budget-feed.js index b2e10780..09c6dc93 100644 --- a/docker/grok/budget-feed.js +++ b/docker/grok/budget-feed.js @@ -77,7 +77,10 @@ function bareVerb(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 { "tool.execute.before": async (input) => { const status = await sdk("GET", "/budget/status", null); diff --git a/docker/grok/intake-tools.js b/docker/grok/intake-tools.js index 6c732d29..2c580f24 100644 --- a/docker/grok/intake-tools.js +++ b/docker/grok/intake-tools.js @@ -20,7 +20,11 @@ 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: { propose_draft: tool({ description: diff --git a/docker/grok/secret-scrub.js b/docker/grok/secret-scrub.js index 2a208300..ef80fd9f 100644 --- a/docker/grok/secret-scrub.js +++ b/docker/grok/secret-scrub.js @@ -164,7 +164,11 @@ function denyBash(command) { 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 { "tool.execute.before": async (input, output) => { const tool = input?.tool; diff --git a/docker/grok/secretary-tools.js b/docker/grok/secretary-tools.js index 4803b689..5c759b3d 100644 --- a/docker/grok/secretary-tools.js +++ b/docker/grok/secretary-tools.js @@ -66,7 +66,12 @@ async function callBackend(method, path, body) { 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: { read_company_state: tool({ description: diff --git a/roboco/llm/providers/grok.py b/roboco/llm/providers/grok.py index f5a24d0b..b02a7f6a 100644 --- a/roboco/llm/providers/grok.py +++ b/roboco/llm/providers/grok.py @@ -260,11 +260,14 @@ class GrokProvider(AgentProvider): base_url = config.provider_base_url or _DEFAULT_XAI_BASE_URL 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", - f"OPENAI_BASE_URL={base_url}", + f"XAI_API_KEY={config.provider_auth_token}", "-e", - f"OPENAI_API_KEY={config.provider_auth_token}", + f"XAI_BASE_URL={base_url}", # Operational inputs for the grok image entrypoint. "-e", f"ROBOCO_AGENT_MODEL={config.model}", diff --git a/roboco/llm/providers/opencode_config.py b/roboco/llm/providers/opencode_config.py index c7885923..5da7ae31 100644 --- a/roboco/llm/providers/opencode_config.py +++ b/roboco/llm/providers/opencode_config.py @@ -7,18 +7,30 @@ sets (``OPENAI_*`` + ``ROBOCO_*``) plus the mounted Claude Code as importable Python (not a shell heredoc) makes the translation unit-testable. Config shape per opencode docs (https://opencode.ai/docs/config): - * ``provider.`` — ``@ai-sdk/openai`` (the Responses API; see ``_PROVIDER_NPM``) - with ``options.baseURL`` / ``options.apiKey`` / ``options.timeout`` / - ``options.chunkTimeout``; ``model`` selects ``/``. + * NO ``provider`` block. opencode's BUILT-IN xai provider drives + grok-build-0.1; ANY custom ``provider.xai`` block (even just ``options``) + 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/``. * ``mcp.`` — ``{type:"local", command:[...], environment:{...}}``; this is where RoboCo's gateway servers (roboco-flow / roboco-do / ...) are wired, 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 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 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`` (``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 (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 @@ -50,74 +57,30 @@ from typing import Any _OPENCODE_SCHEMA = "https://opencode.ai/config.json" _PROVIDER_ID = "xai" -# grok-build-0.1 is driven through the OpenAI **Responses** API (opencode calls -# model.responses()). Only @ai-sdk/openai implements that — @ai-sdk/openai-compatible -# is chat/completions only and errors with "responses is not a function". -# Confirmed via a live opencode run against api.x.ai/v1. -_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; -# 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()] +# We do NOT override provider..npm. opencode's BUILT-IN xai provider already +# drives grok-build-0.1 with working tool-calls (verified live); a custom `npm` +# (e.g. @ai-sdk/openai) is not resolvable from opencode's module path and makes +# the model fail to resolve (ProviderModelNotFoundError). The xAI key is injected +# 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 (secret-scrub / budget-feed / the per-role tool plugins) are NOT listed +# in the config `plugin:` array — opencode 1.17.8 does not register a plugin's +# hooks/tools when it is referenced by absolute path there. They are baked into +# the plugin AUTO-DISCOVERY dir (~/.config/opencode/plugin/, i.e. +# /home/agent/.config/opencode/plugin/ in the image) instead, which registers +# both tools and hooks (verified live against grok-build-0.1). # 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 -# 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" -# 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) class OpencodeGuards: @@ -127,15 +90,13 @@ class OpencodeGuards: 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 /tmp — so default ``allow``: the container is the sandbox and secret-scrub - still blocks credential files); the timeouts bound a single model call and - abort an idle stream; ``disable_subagents`` removes the subagent ``task`` tool. + still blocks credential files); ``disable_subagents`` removes the subagent + ``task`` tool. """ bash_permission: str = "allow" edit_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 @@ -166,31 +127,25 @@ def translate_mcp_servers(mcp_config: dict[str, Any]) -> dict[str, Any]: def build_opencode_config( mcp_config: dict[str, Any], - target: XaiTarget, + model: str, *, 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.""" + """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() - plugins = [*_PLUGINS, *(extra_plugins or [])] config: dict[str, Any] = { "$schema": _OPENCODE_SCHEMA, - "provider": { - _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}", + "model": f"{_PROVIDER_ID}/{model}", "mcp": translate_mcp_servers(mcp_config), "permission": { "bash": guards.bash_permission, @@ -202,9 +157,6 @@ def build_opencode_config( "external_directory": guards.external_directory_permission, }, "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: # 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: - """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"), - api_key=os.environ.get("OPENAI_API_KEY", ""), - model=os.environ.get("ROBOCO_AGENT_MODEL", "grok-build-0.1"), - ) + """Entrypoint: read env + mounted mcp-config.json, write opencode.json. + + The xAI key + base URL are NOT read here — they reach opencode's built-in + xai provider via the ``XAI_API_KEY`` / ``XAI_BASE_URL`` env vars. + """ + model = os.environ.get("ROBOCO_AGENT_MODEL", "grok-build-0.1") mcp_path = os.environ.get("ROBOCO_MCP_CONFIG", "/app/mcp-config.json") 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 @@ -243,12 +195,6 @@ def main() -> int: external_directory_permission=os.environ.get( "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. @@ -257,10 +203,9 @@ def main() -> int: config = build_opencode_config( _load_mcp_config(mcp_path), - target, + model, instruction_paths=instructions, guards=guards, - extra_plugins=_extra_plugins(), ) out = Path(out_path) out.parent.mkdir(parents=True, exist_ok=True) diff --git a/roboco/runtime/orchestrator.py b/roboco/runtime/orchestrator.py index 0ba44300..4d34406d 100644 --- a/roboco/runtime/orchestrator.py +++ b/roboco/runtime/orchestrator.py @@ -3442,10 +3442,13 @@ class AgentOrchestrator: cmd.extend(["-v", f"{opencode_host}:/home/agent/.local/share/opencode"]) 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", - f"OPENAI_BASE_URL={base_url or 'https://api.x.ai/v1'}", + f"XAI_API_KEY={auth_token or ''}", "-e", - f"OPENAI_API_KEY={auth_token or ''}", + f"XAI_BASE_URL={base_url or 'https://api.x.ai/v1'}", "-e", f"ROBOCO_AGENT_MODEL={spec.model}", "-e", diff --git a/tests/unit/llm/test_opencode_config.py b/tests/unit/llm/test_opencode_config.py index dd00760b..47c4b6d0 100644 --- a/tests/unit/llm/test_opencode_config.py +++ b/tests/unit/llm/test_opencode_config.py @@ -2,23 +2,13 @@ from __future__ import annotations -import os -from unittest.mock import patch - from roboco.llm.providers.opencode_config import ( - _DEFAULT_CHUNK_TIMEOUT_MS, - _DEFAULT_REQUEST_TIMEOUT_MS, OpencodeGuards, - XaiTarget, - _env_int, - _extra_plugins, build_opencode_config, translate_mcp_servers, ) -_TARGET = XaiTarget( - base_url="https://api.x.ai/v1", api_key="xai-key", model="grok-build-0.1" -) +_MODEL = "grok-build-0.1" _MCP = { "mcpServers": { @@ -72,57 +62,28 @@ def test_translate_mcp_servers_omits_environment_when_no_env() -> None: 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( _MCP, - _TARGET, + _MODEL, instruction_paths=["/app/system-prompt.md"], ) - provider = cfg["provider"]["xai"] - # grok-build-0.1 needs the Responses API → @ai-sdk/openai, not -compatible. - assert provider["npm"] == "@ai-sdk/openai" - assert provider["options"]["baseURL"] == "https://api.x.ai/v1" - assert provider["options"]["apiKey"] == "xai-key" - assert "grok-build-0.1" in provider["models"] + # CRITICAL: NO provider block. ANY provider.xai block breaks plugin-tool + # registration on opencode 1.17.8 (verified live). The built-in xai provider + # drives the model; the key reaches it via the XAI_API_KEY env var. + assert "provider" not in cfg # Top-level model selector is "/". assert cfg["model"] == "xai/grok-build-0.1" # Gateway servers carried through. assert "roboco-flow" in cfg["mcp"] 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: - # 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_has_no_plugin_array() -> None: + # opencode 1.17.8 ignores config `plugin:`-array absolute paths for + # registration; plugins live in the auto-discovery dir, baked into the images. + cfg = build_opencode_config(_MCP, _MODEL, instruction_paths=[]) + assert "plugin" not in cfg 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. cfg = build_opencode_config( {}, - _TARGET, + _MODEL, instruction_paths=[], 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: cfg = build_opencode_config( {}, - _TARGET, + _MODEL, instruction_paths=[], 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: # 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". - cfg = build_opencode_config(_MCP, _TARGET, instruction_paths=[]) + cfg = build_opencode_config(_MCP, _MODEL, instruction_paths=[]) assert cfg["permission"]["external_directory"] == "allow" def test_build_opencode_config_external_directory_is_tunable() -> None: cfg = build_opencode_config( {}, - _TARGET, + _MODEL, 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: # 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. - cfg = build_opencode_config(_MCP, _TARGET, instruction_paths=[]) + cfg = build_opencode_config(_MCP, _MODEL, instruction_paths=[]) assert cfg["tools"] == {"task": False} def test_build_opencode_config_subagents_can_be_re_enabled() -> None: cfg = build_opencode_config( _MCP, - _TARGET, + _MODEL, instruction_paths=[], guards=OpencodeGuards(disable_subagents=False), ) assert "tools" not in cfg - - -def test_build_opencode_config_sets_default_timeouts() -> None: - # Both timeouts land under provider..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 diff --git a/tests/unit/llm/test_providers.py b/tests/unit/llm/test_providers.py index beb1b6d0..2a3f221c 100644 --- a/tests/unit/llm/test_providers.py +++ b/tests/unit/llm/test_providers.py @@ -5,7 +5,7 @@ GrokProvider (xAI / OpenAI protocol) — especially the safety properties an OpenAI-protocol agent provider must hold: * 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. """ @@ -177,7 +177,7 @@ async def test_grok_spawn_requires_mcp_config() -> 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() provider = GrokProvider(host, image="roboco-agent-grok:test") with patch( @@ -185,8 +185,10 @@ async def test_grok_spawn_injects_openai_env_and_no_anthropic_leak() -> None: ) as exec_mock: await provider.spawn(_config(), initial_prompt="do the work") cmd = list(exec_mock.call_args.args) - assert "OPENAI_BASE_URL=https://api.x.ai/v1" in cmd - assert "OPENAI_API_KEY=xai-secret-key" in cmd + # opencode's built-in xai provider reads XAI_API_KEY / XAI_BASE_URL (no + # 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. assert not any(c.startswith("ANTHROPIC_BASE_URL=") 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: await provider.spawn(_config(provider_base_url=None)) 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: diff --git a/tests/unit/runtime/test_interactive_grok_spawn.py b/tests/unit/runtime/test_interactive_grok_spawn.py index 40c015bb..72d6b2b0 100644 --- a/tests/unit/runtime/test_interactive_grok_spawn.py +++ b/tests/unit/runtime/test_interactive_grok_spawn.py @@ -1,7 +1,7 @@ """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 -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. """ @@ -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( _intake_spec( "grok", @@ -57,8 +57,8 @@ def test_intake_grok_uses_openai_env_and_opencode_mount() -> None: grok_variant="minimal", ) ) - assert "OPENAI_BASE_URL=https://api.x.ai/v1" in cmd - assert "OPENAI_API_KEY=xai-key" in cmd + assert "XAI_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_SYSTEM_PROMPT=/app/system-prompt.md" 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_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" @@ -118,7 +118,7 @@ def test_secretary_grok_uses_openai_env_and_grok_image() -> None: model="grok-build-0.1", ) 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 # The HMAC identity the directive tools authenticate with survives. assert "ROBOCO_AGENT_TOKEN=hmac-secretary" in cmd