feat(providers): native Grok runtime — opencode image, config gen, panel key

Complete the native Grok (xAI) path so grok-build-0.1 runs as a real
RoboCo agent, not just the provider seam.

- roboco-agent-grok image (docker/agent-grok.Dockerfile): FROM agent-base
  + opencode (the OpenAI-protocol runtime). One image serves every role;
  role behaviour comes from the mounted manifest / mcp-config / system
  prompt, exactly as on the Claude path.
- Entrypoint renders opencode.json at spawn from the GrokProvider env
  contract + the mounted Claude Code mcp-config.json
  (roboco.llm.providers.opencode_config): translates RoboCo's gateway
  servers (roboco-flow / roboco-do / ...) into opencode's mcp block,
  declares the xAI OpenAI-compatible provider + model, and wires
  permissions + instructions. Pure, unit-tested translation.
- Orchestrator registers GrokProvider with the registry-qualified image
  (_qualify_agent_image) so it resolves in local and registry deploys.
- Compose (both files + the registry compose) gain an agent-grok-image
  builder service.
- Panel: a Grok (xAI) API key card on the AI Providers page, plus the
  grok ModelProvider value.

KNOWN PARITY GAP (opencode runtime): the bash-guard PAT-scrub and the
transcript-based usage/cost capture are Claude Code hooks and do not
transfer to opencode. bash permission is operator-tunable
(ROBOCO_GROK_BASH_PERMISSION) so a deployment can fail closed until a
security/usage-parity opencode plugin lands. That plugin and live E2E
validation are the remaining work to finalize with xAI.
This commit is contained in:
Renn F
2026-06-18 07:12:57 +02:00
parent a956083f9f
commit 085414dcf5
13 changed files with 444 additions and 2 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
### Added ### Added
- **Pluggable agent providers + a Grok (xAI) backend.** A new `roboco/llm/providers/` seam (an `AgentProvider` lifecycle ABC + a `ProviderRegistry` keyed by `ModelProvider`) lets the orchestrator drive agent backends other than Claude Code. The first is `GrokProvider` for xAI's `grok-build-0.1`: xAI is OpenAI-compatible only (no Anthropic-Messages endpoint), so a Grok agent runs an OpenAI-protocol runtime pointed at `https://api.x.ai/v1` rather than the `ANTHROPIC_BASE_URL` injection the other providers use — and it reuses the orchestrator's existing mount/auth assembly, so it gets the same MCP gateway + tool-manifest wiring as every other agent by construction (and passes its prompt via env, never an argv positional). The change is purely additive: only `GROK` routes through the registry; Anthropic / Ollama Cloud / self-hosted spawns are untouched. Includes the `grok` enum value (migration 038), a seeded Grok provider row (migration 039), a `grok-build-0.1` catalog entry, and `GET/PUT /api/providers/grok-key` to store the xAI key (Fernet-encrypted, reusing the existing provider-key machinery). The dedicated `roboco-agent-grok` image plus the exact OpenAI-protocol CLI invocation are the remaining piece to finalize together with xAI. - **Pluggable agent providers + a Grok (xAI) backend.** A new `roboco/llm/providers/` seam (an `AgentProvider` lifecycle ABC + a `ProviderRegistry` keyed by `ModelProvider`) lets the orchestrator drive agent backends other than Claude Code. The first is `GrokProvider` for xAI's `grok-build-0.1`: xAI is OpenAI-compatible only (no Anthropic-Messages endpoint), so a Grok agent runs an OpenAI-protocol runtime pointed at `https://api.x.ai/v1` rather than the `ANTHROPIC_BASE_URL` injection the other providers use — and it reuses the orchestrator's existing mount/auth assembly, so it gets the same MCP gateway + tool-manifest wiring as every other agent by construction (and passes its prompt via env, never an argv positional). The change is purely additive: only `GROK` routes through the registry; Anthropic / Ollama Cloud / self-hosted spawns are untouched. Includes the `grok` enum value (migration 038), a seeded Grok provider row (migration 039), a `grok-build-0.1` catalog entry, and `GET/PUT /api/providers/grok-key` + a Settings panel card to store the xAI key (Fernet-encrypted, reusing the existing provider-key machinery). Ships the full native runtime: a first-class `roboco-agent-grok` image (built `FROM` agent-base, adds opencode; wired into both compose files, the registry compose, and the release workflow) whose entrypoint renders an `opencode.json` at spawn — translating RoboCo's MCP gateway servers into opencode's config and declaring the xAI provider — then runs opencode. KNOWN PARITY GAP: RoboCo's bash-guard (PAT-scrub) and transcript-based usage/cost capture are Claude Code hooks that do not transfer to the opencode runtime; the `bash` permission is operator-tunable so a deployment can fail closed until a security/usage-parity opencode plugin lands. That plugin and live end-to-end validation are the remaining work to finalize with xAI.
## [0.6.0] - 2026-06-17 ## [0.6.0] - 2026-06-17
+5
View File
@@ -160,6 +160,11 @@ services:
entrypoint: ["/bin/sh", "-c", "echo 'agent-pr-reviewer image present'"] entrypoint: ["/bin/sh", "-c", "echo 'agent-pr-reviewer image present'"]
restart: "no" restart: "no"
agent-grok-image:
image: ${ROBOCO_REGISTRY:-ghcr.io/rennf93}/roboco-agent-grok:${ROBOCO_VERSION:-latest}
entrypoint: ["/bin/sh", "-c", "echo 'agent-grok image present'"]
restart: "no"
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# Orchestrator — API server + agent spawner # Orchestrator — API server + agent spawner
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
+13
View File
@@ -229,6 +229,19 @@ services:
depends_on: depends_on:
- agent-base-image - agent-base-image
# ==========================================================================
# Agent Grok Image Builder (xAI grok-build-0.1 via opencode, OpenAI protocol)
# ==========================================================================
agent-grok-image:
build:
context: .
dockerfile: docker/agent-grok.Dockerfile
image: roboco-agent-grok
entrypoint: ["/bin/sh", "-c", 'echo "Agent Grok image built"']
restart: "no"
depends_on:
- agent-base-image
# ========================================================================== # ==========================================================================
# Orchestrator - API Server + Agent Spawner # Orchestrator - API Server + Agent Spawner
# ========================================================================== # ==========================================================================
+13
View File
@@ -229,6 +229,19 @@ services:
depends_on: depends_on:
- agent-base-image - agent-base-image
# ==========================================================================
# Agent Grok Image Builder (xAI grok-build-0.1 via opencode, OpenAI protocol)
# ==========================================================================
agent-grok-image:
build:
context: .
dockerfile: docker/agent-grok.Dockerfile
image: roboco-agent-grok
entrypoint: ["/bin/sh", "-c", 'echo "Agent Grok image built"']
restart: "no"
depends_on:
- agent-base-image
# ========================================================================== # ==========================================================================
# Orchestrator - API Server + Agent Spawner # Orchestrator - API Server + Agent Spawner
# ========================================================================== # ==========================================================================
+32
View File
@@ -0,0 +1,32 @@
# Grok (xAI) Agent Image
# =============================================================================
# Runs grok-build-0.1 through the opencode CLI (OpenAI protocol) instead of
# Claude Code, while reusing the base image's roboco venv + uv + the RoboCo MCP
# gateway servers. The entrypoint renders opencode.json from the spawn env +
# mounted mcp-config.json (see roboco.llm.providers.opencode_config) and runs
# opencode. One runtime image serves every role — role behaviour comes from the
# mounted system prompt / manifest / mcp-config, exactly as on the Claude path.
# =============================================================================
FROM roboco-agent-base
USER root
# opencode — the OpenAI-protocol agent runtime. The @ai-sdk/openai-compatible
# package backs the custom xAI provider declared in the generated opencode.json;
# opencode also resolves it at runtime, but pre-installing keeps first spawn off
# the network.
RUN npm install -g opencode-ai @ai-sdk/openai-compatible \
&& npm cache clean --force \
&& rm -rf /root/.npm /tmp/*
# 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
USER agent
LABEL role="grok-runtime"
LABEL description="Grok (xAI) agent runtime — grok-build-0.1 via opencode (OpenAI protocol)"
ENTRYPOINT ["/app/scripts/grok-agent-entrypoint.sh"]
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Entrypoint for the roboco-agent-grok image.
#
# Renders opencode.json from the RoboCo spawn env (OPENAI_* + ROBOCO_*, set by
# GrokProvider) plus the mounted Claude Code mcp-config.json, then runs opencode
# non-interactively. opencode speaks the OpenAI protocol, so grok-build-0.1 runs
# natively against api.x.ai/v1 with no shim, while still reaching the RoboCo MCP
# gateway (roboco-flow / roboco-do / ...) translated into opencode's mcp config.
set -euo pipefail
# Generate opencode.json (provider + model + MCP gateway + permissions +
# instructions). Writes to opencode's global config dir by default.
python -m roboco.llm.providers.opencode_config
# Run the agent. The prompt comes from an env var (never an untrusted argv
# positional); `--` separates it from flags so a prompt starting with `--`
# cannot be parsed as CLI options. The model also comes from the rendered
# config; --model is passed explicitly as belt-and-suspenders.
exec opencode run \
--model "xai/${ROBOCO_AGENT_MODEL:-grok-build-0.1}" \
-- "${ROBOCO_INITIAL_PROMPT:-}"
@@ -4,8 +4,10 @@ import { useEffect, useMemo, useState, useCallback } from "react";
import { import {
useApplyMode, useApplyMode,
useCatalog, useCatalog,
useGrokKey,
useOllamaKey, useOllamaKey,
useRoutingMode, useRoutingMode,
useSetGrokKey,
useSetOllamaKey, useSetOllamaKey,
useSelfHostedModels, useSelfHostedModels,
} from "@/hooks/use-providers"; } from "@/hooks/use-providers";
@@ -124,6 +126,33 @@ export function AIRoutingCard() {
} }
}; };
// --- Grok (xAI) API key ---
const { data: grokKeyStatus } = useGrokKey();
const setGrokKeyMut = useSetGrokKey();
const hasGrokKey = !!grokKeyStatus?.has_key;
const [grokKey, setGrokKey] = useState("");
const [clearGrokKey, setClearGrokKey] = useState(false);
const saveGrokKey = async () => {
try {
if (clearGrokKey) {
await setGrokKeyMut.mutateAsync("");
toast.success("Grok key cleared");
} else {
if (!grokKey.trim()) {
toast.error("Enter a key first");
return;
}
await setGrokKeyMut.mutateAsync(grokKey);
toast.success("Grok key saved");
}
setGrokKey("");
setClearGrokKey(false);
} catch (e) {
toast.error("Save failed: " + errMsg(e));
}
};
// --- Mix mode state: agent_slug → model_name --- // --- Mix mode state: agent_slug → model_name ---
const initialMix = useMemo(() => { const initialMix = useMemo(() => {
const map: Record<string, string> = {}; const map: Record<string, string> = {};
@@ -300,6 +329,56 @@ export function AIRoutingCard() {
<Separator /> <Separator />
{/* -------- Grok (xAI) key -------- */}
<section className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">Grok (xAI) API key</Label>
{hasGrokKey ? (
<Badge className="bg-emerald-500/10 text-emerald-600 border-0">
<KeyRound className="h-3 w-3" /> key set
</Badge>
) : (
<Badge className="bg-amber-500/10 text-amber-600 border-0">
<Key className="h-3 w-3" /> not set
</Badge>
)}
</div>
<div className="flex gap-2">
<Input
type="password"
value={grokKey}
onChange={(e) => setGrokKey(e.target.value)}
placeholder={
hasGrokKey ? "•••••••••••• (leave blank to keep)" : "xai-…"
}
disabled={clearGrokKey}
/>
<Button onClick={saveGrokKey} disabled={setGrokKeyMut.isPending}>
{setGrokKeyMut.isPending ? "Saving…" : "Save"}
</Button>
</div>
{hasGrokKey ? (
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<Checkbox
checked={clearGrokKey}
onCheckedChange={(checked) => {
const next = checked === true;
setClearGrokKey(next);
if (next) setGrokKey("");
}}
/>
Clear the stored key
</label>
) : (
<p className="text-xs text-muted-foreground">
Used for grok-build-0.1 at api.x.ai/v1. Stored Fernet-encrypted
server-side; never returned by the API.
</p>
)}
</section>
<Separator />
{/* -------- Self-Hosted LLM -------- */} {/* -------- Self-Hosted LLM -------- */}
<SelfHostedSection <SelfHostedSection
testResult={selfHostedTestResult} testResult={selfHostedTestResult}
+20
View File
@@ -9,6 +9,7 @@ export const providerKeys = {
all: ["providers"] as const, all: ["providers"] as const,
catalog: () => [...providerKeys.all, "catalog"] as const, catalog: () => [...providerKeys.all, "catalog"] as const,
ollamaKey: () => [...providerKeys.all, "ollama-key"] as const, ollamaKey: () => [...providerKeys.all, "ollama-key"] as const,
grokKey: () => [...providerKeys.all, "grok-key"] as const,
mode: () => [...providerKeys.all, "mode"] as const, mode: () => [...providerKeys.all, "mode"] as const,
selfHostedConfig: () => [...providerKeys.all, "self-hosted-config"] as const, selfHostedConfig: () => [...providerKeys.all, "self-hosted-config"] as const,
selfHostedModels: () => [...providerKeys.all, "self-hosted-models"] as const, selfHostedModels: () => [...providerKeys.all, "self-hosted-models"] as const,
@@ -43,6 +44,25 @@ export function useSetOllamaKey() {
}); });
} }
export function useGrokKey() {
return useQuery({
queryKey: providerKeys.grokKey(),
queryFn: () => providersApi.getGrokKey(),
staleTime: 60_000,
});
}
export function useSetGrokKey() {
const qc = useQueryClient();
return useMutation({
mutationFn: (apiKey: string) => providersApi.setGrokKey(apiKey),
onSuccess: () => {
qc.invalidateQueries({ queryKey: providerKeys.grokKey() });
qc.invalidateQueries({ queryKey: providerKeys.mode() });
},
});
}
export function useRoutingMode() { export function useRoutingMode() {
return useQuery({ return useQuery({
queryKey: providerKeys.mode(), queryKey: providerKeys.mode(),
+17
View File
@@ -16,6 +16,11 @@ export interface OllamaKeyStatus {
enabled: boolean; enabled: boolean;
} }
export interface GrokKeyStatus {
has_key: boolean;
enabled: boolean;
}
export interface ModelAssignment { export interface ModelAssignment {
id: string; id: string;
scope: AssignmentScope; scope: AssignmentScope;
@@ -86,6 +91,18 @@ export const providersApi = {
return data; return data;
}, },
getGrokKey: async (): Promise<GrokKeyStatus> => {
const { data } = await api.get<GrokKeyStatus>("/providers/grok-key");
return data;
},
setGrokKey: async (apiKey: string): Promise<GrokKeyStatus> => {
const { data } = await api.put<GrokKeyStatus>("/providers/grok-key", {
api_key: apiKey,
});
return data;
},
getMode: async (): Promise<ModeSnapshot> => { getMode: async (): Promise<ModeSnapshot> => {
const { data } = await api.get<ModeSnapshot>("/providers"); const { data } = await api.get<ModeSnapshot>("/providers");
return data; return data;
+1
View File
@@ -104,6 +104,7 @@ export enum ModelProvider {
OLLAMA_CLOUD = "ollama_cloud", OLLAMA_CLOUD = "ollama_cloud",
OPENAI = "openai", OPENAI = "openai",
LOCAL = "local", LOCAL = "local",
GROK = "grok",
} }
export enum AssignmentScope { export enum AssignmentScope {
+142
View File
@@ -0,0 +1,142 @@
"""Generate an ``opencode.json`` for a Grok (xAI) agent at container start.
The ``roboco-agent-grok`` image's entrypoint runs ``python -m
roboco.llm.providers.opencode_config`` to turn the env contract ``GrokProvider``
sets (``OPENAI_*`` + ``ROBOCO_*``) plus the mounted Claude Code
``mcp-config.json`` into the ``opencode.json`` that opencode reads. Keeping this
as importable Python (not a shell heredoc) makes the translation unit-testable.
Config shape per opencode docs (https://opencode.ai/docs/config):
* ``provider.<id>`` ``@ai-sdk/openai-compatible`` with ``options.baseURL`` /
``options.apiKey``; ``model`` selects ``<id>/<model>``.
* ``mcp.<name>`` ``{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).
KNOWN PARITY GAP (tracked for the opencode-plugin follow-up with xAI): RoboCo's
bash-guard (PAT-scrub) and transcript-based usage/cost capture are Claude Code
hooks; they do not transfer to the opencode runtime. ``bash`` permission is
operator-tunable (``ROBOCO_GROK_BASH_PERMISSION``) so a deployment can fail
closed (``deny``/``ask``) until a security-parity opencode plugin lands.
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any
_OPENCODE_SCHEMA = "https://opencode.ai/config.json"
_PROVIDER_ID = "xai"
_OPENAI_COMPAT_NPM = "@ai-sdk/openai-compatible"
@dataclass(frozen=True)
class XaiTarget:
"""The xAI endpoint a Grok agent talks to."""
base_url: str
api_key: str
model: str
def translate_mcp_servers(mcp_config: dict[str, Any]) -> dict[str, Any]:
"""Translate Claude Code ``mcpServers`` into opencode's ``mcp`` block.
``{"command": "uv", "args": [...], "env": {...}}`` becomes
``{"type": "local", "command": ["uv", ...], "environment": {...},
"enabled": True}``.
"""
servers = mcp_config.get("mcpServers", {})
out: dict[str, Any] = {}
for name, spec in servers.items():
command = spec.get("command")
args = list(spec.get("args", []))
cmd_list = [command, *args] if command else args
entry: dict[str, Any] = {
"type": "local",
"command": cmd_list,
"enabled": True,
}
env = spec.get("env")
if env:
entry["environment"] = env
out[name] = entry
return out
def build_opencode_config(
mcp_config: dict[str, Any],
target: XaiTarget,
*,
instruction_paths: list[str],
bash_permission: str = "allow",
edit_permission: str = "allow",
) -> dict[str, Any]:
"""Build the full ``opencode.json`` dict for a Grok agent."""
return {
"$schema": _OPENCODE_SCHEMA,
"provider": {
_PROVIDER_ID: {
"npm": _OPENAI_COMPAT_NPM,
"name": "xAI",
"options": {"baseURL": target.base_url, "apiKey": target.api_key},
"models": {target.model: {"name": target.model}},
}
},
"model": f"{_PROVIDER_ID}/{target.model}",
"mcp": translate_mcp_servers(mcp_config),
"permission": {"bash": bash_permission, "edit": edit_permission},
"instructions": instruction_paths,
}
def _load_mcp_config(path: str) -> dict[str, Any]:
"""Load the mounted mcp-config.json, tolerating a missing/invalid file."""
try:
with Path(path).open() as fh:
data: dict[str, Any] = json.load(fh)
return data
except (OSError, json.JSONDecodeError):
return {}
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"),
)
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
# the agent's working directory (cwd is the per-agent workspace at spawn).
out_path = os.environ.get(
"ROBOCO_OPENCODE_CONFIG",
str(Path.home() / ".config" / "opencode" / "opencode.json"),
)
bash_perm = os.environ.get("ROBOCO_GROK_BASH_PERMISSION", "allow")
# Instructions = system prompt + the SessionStart briefing when mounted.
candidates = [system_prompt, "/app/briefing.md"]
instructions = [p for p in candidates if p and Path(p).exists()]
config = build_opencode_config(
_load_mcp_config(mcp_path),
target,
instruction_paths=instructions,
bash_permission=bash_perm,
)
out = Path(out_path)
out.parent.mkdir(parents=True, exist_ok=True)
with out.open("w") as fh:
json.dump(config, fh, indent=2)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+7 -1
View File
@@ -1972,7 +1972,13 @@ class AgentOrchestrator:
from roboco.models.base import ModelProvider from roboco.models.base import ModelProvider
registry = ProviderRegistry() registry = ProviderRegistry()
registry.register(ModelProvider.GROK, GrokProvider(self)) # Qualify the grok image with the registry namespace + tag so it
# resolves in both local-build and registry deploys (parity with
# get_agent_image for the Claude path).
registry.register(
ModelProvider.GROK,
GrokProvider(self, image=_qualify_agent_image("roboco-agent-grok")),
)
self._provider_registry = registry self._provider_registry = registry
return self._provider_registry return self._provider_registry
+93
View File
@@ -0,0 +1,93 @@
"""Tests for the Grok opencode.json generator (RoboCo MCP -> opencode config)."""
from __future__ import annotations
from roboco.llm.providers.opencode_config import (
XaiTarget,
build_opencode_config,
translate_mcp_servers,
)
_TARGET = XaiTarget(
base_url="https://api.x.ai/v1", api_key="xai-key", model="grok-build-0.1"
)
_MCP = {
"mcpServers": {
"roboco-flow": {
"command": "uv",
"args": ["run", "--no-sync", "python", "-m", "roboco.mcp.flow_server"],
"env": {
"ROBOCO_AGENT_ID": "uuid-1",
"UV_PROJECT_ENVIRONMENT": "/app/.venv",
},
},
"roboco-do": {
"command": "uv",
"args": ["run", "--no-sync", "python", "-m", "roboco.mcp.do_server"],
"env": {"ROBOCO_AGENT_ID": "uuid-1"},
},
}
}
def test_translate_mcp_servers_shape() -> None:
out = translate_mcp_servers(_MCP)
flow = out["roboco-flow"]
assert flow["type"] == "local"
assert flow["enabled"] is True
# command + args collapse into a single command array (opencode shape).
assert flow["command"] == [
"uv",
"run",
"--no-sync",
"python",
"-m",
"roboco.mcp.flow_server",
]
# env -> environment (opencode key).
assert flow["environment"]["ROBOCO_AGENT_ID"] == "uuid-1"
assert "env" not in flow
assert set(out) == {"roboco-flow", "roboco-do"}
def test_translate_mcp_servers_empty() -> None:
assert translate_mcp_servers({}) == {}
assert translate_mcp_servers({"mcpServers": {}}) == {}
def test_translate_mcp_servers_omits_environment_when_no_env() -> None:
out = translate_mcp_servers(
{"mcpServers": {"x": {"command": "uv", "args": ["run"]}}}
)
assert "environment" not in out["x"]
assert out["x"]["command"] == ["uv", "run"]
def test_build_opencode_config_provider_and_model() -> None:
cfg = build_opencode_config(
_MCP,
_TARGET,
instruction_paths=["/app/system-prompt.md"],
)
provider = cfg["provider"]["xai"]
assert provider["npm"] == "@ai-sdk/openai-compatible"
assert provider["options"]["baseURL"] == "https://api.x.ai/v1"
assert provider["options"]["apiKey"] == "xai-key"
assert "grok-build-0.1" in provider["models"]
# Top-level model selector is "<provider>/<model>".
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"]
def test_build_opencode_config_bash_permission_is_tunable() -> None:
cfg = build_opencode_config(
{},
_TARGET,
instruction_paths=[],
bash_permission="deny",
)
assert cfg["permission"]["bash"] == "deny"
assert cfg["permission"]["edit"] == "allow"