mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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}",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user