mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(grok): start the in-container SDK server + budget feed (Claude parity)
The keystone of the Grok parity work (CEO's "take Claude as baseline, create what's missing" call): the one-shot Grok container now starts the same SDK server the Claude path runs, so the per-verb circuit breaker (the flow/do MCP servers already POST /verb/attempted to it), the per-session budget/loop counters, the terminal-verb tracking, and the SessionEnd post-mortem all work on Grok instead of being silently absent. - entrypoint: launch roboco.agent_sdk.server (bare venv python, not `uv run` which would re-sync the drifted clone lock and stall), wait for /health, reset counters; run opencode WITHOUT exec so the script regains control to run the post-mortem and the silent-exit substitute after the run returns. - budget-feed.js: opencode plugin that gates on /budget/status in tool.execute.before (halt/loop deny — the only place to stop a runaway one-shot run; opencode has no PostToolUse-deny) and records the executed tool + args-hash in tool.execute.after. Fail-open; bare-verb normalization for MCP-namespaced terminal verbs. - silent-exit substitute: on a graceful exit with no terminal verb the entrypoint posts /terminal/force_substitute so the task isn't left stuck claimed/in_progress (Stop-hook parity at the boundary). - opencode_config: wire budget-feed into the plugin array; add ROBOCO_OPENCODE_EXTRA_PLUGINS so per-image role tool plugins load scoped to one role; read the per-role ROBOCO_GROK_EDIT_PERMISSION. Targeted gate green (ruff/mypy/xenon + opencode_config tests; node --check on the plugins; bash -n on the entrypoint).
This commit is contained in:
@@ -21,9 +21,13 @@ 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/*
|
||||||
|
|
||||||
# Command guard / secret-scrub plugin (bash-guard parity for the opencode runtime).
|
# opencode plugins (referenced from the generated opencode.json `plugin:` array):
|
||||||
# Referenced from the generated opencode.json `plugin:` array.
|
# 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/secret-scrub.js /app/opencode-plugins/secret-scrub.js
|
||||||
|
COPY docker/grok/budget-feed.js /app/opencode-plugins/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
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
// opencode plugin — budget / loop / terminal feed for one-shot RoboCo Grok agents.
|
||||||
|
//
|
||||||
|
// Ports the Claude PostToolUse budget hook (docker/scripts/post-tool-budget-hook.sh)
|
||||||
|
// and the Stop hook's terminal-tool tracking (docker/scripts/stop-hook.sh) to
|
||||||
|
// opencode's tool.execute.{before,after}. The in-container SDK server
|
||||||
|
// (roboco.agent_sdk.server, :9000) is the same long-lived process the Claude
|
||||||
|
// path runs — the grok entrypoint starts it before `opencode run`. The flow/do
|
||||||
|
// MCP servers already POST /verb/attempted to it for the per-verb circuit
|
||||||
|
// breaker, so starting it + this feed restores the budget/loop/terminal cluster
|
||||||
|
// on Grok (Claude-parity, the CEO's "create what's missing" call).
|
||||||
|
//
|
||||||
|
// before: read /budget/status and DENY (throw) on a hard halt, or a loop with
|
||||||
|
// loop_action=halt. opencode has no PostToolUse-deny, so the pre-exec
|
||||||
|
// gate is the only place to stop a runaway one-shot run from burning
|
||||||
|
// the cost cap mid-turn. The loop trips one call later than Claude
|
||||||
|
// (record is in `after`) but still halts the burn.
|
||||||
|
// after: record the executed tool on /terminal/tool_recorded (so a graceful
|
||||||
|
// terminal verb is recognized) and /budget/tool_called (advances the
|
||||||
|
// breaker/loop counters and feeds the post-exit post-mortem).
|
||||||
|
//
|
||||||
|
// Fail-open everywhere: a missing / slow / non-2xx SDK never blocks the agent.
|
||||||
|
// The interactive serve images (intake / secretary) own :9000 for the human-turn
|
||||||
|
// receiver and run NO SDK server, so these POSTs 404 there and are ignored —
|
||||||
|
// those roles don't claim tasks or loop on verbs, so they need no budget feed.
|
||||||
|
|
||||||
|
const SDK_URL = process.env.ROBOCO_SDK_URL || "http://localhost:9000";
|
||||||
|
|
||||||
|
async function sdk(method, path, body) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${SDK_URL}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: body ? { "Content-Type": "application/json" } : undefined,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
signal: AbortSignal.timeout(2000),
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return await res.json();
|
||||||
|
} catch {
|
||||||
|
return null; // fail-open — never block the agent on SDK reachability
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canonical, dependency-free serialization (sorted keys) so the SDK's loop
|
||||||
|
// detector sees identical (tool, args) calls as identical. Need NOT match the
|
||||||
|
// Claude hook's sha256 — only be stable within one session.
|
||||||
|
function canonical(value) {
|
||||||
|
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
||||||
|
if (Array.isArray(value)) return "[" + value.map(canonical).join(",") + "]";
|
||||||
|
const keys = Object.keys(value).sort();
|
||||||
|
return (
|
||||||
|
"{" +
|
||||||
|
keys.map((k) => JSON.stringify(k) + ":" + canonical(value[k])).join(",") +
|
||||||
|
"}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function argsHash(args) {
|
||||||
|
const s = canonical(args ?? {});
|
||||||
|
let h = 0x811c9dc5; // FNV-1a, 32-bit
|
||||||
|
for (let i = 0; i < s.length; i++) {
|
||||||
|
h ^= s.charCodeAt(i);
|
||||||
|
h = Math.imul(h, 0x01000193);
|
||||||
|
}
|
||||||
|
return (h >>> 0).toString(16).padStart(8, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
// opencode namespaces an MCP tool as "<server>_<verb>" (or "<server>.<verb>");
|
||||||
|
// the Claude path's verbs arrive bare. Strip a known roboco-* server prefix so
|
||||||
|
// the SDK recognizes a terminal verb (i_am_idle / i_am_done / ...) — the SDK's
|
||||||
|
// own "__"-split is a no-op on the already-bare verb this returns.
|
||||||
|
// UNVERIFIED-LIVE: opencode's exact MCP tool-name shape; the strip is defensive.
|
||||||
|
function bareVerb(tool) {
|
||||||
|
const mcp = tool.match(/^mcp__[a-z0-9-]+__(.+)$/);
|
||||||
|
if (mcp) return mcp[1];
|
||||||
|
const pref = tool.match(/^roboco-[a-z-]+[_.](.+)$/);
|
||||||
|
if (pref) return pref[1];
|
||||||
|
return tool;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async () => {
|
||||||
|
return {
|
||||||
|
"tool.execute.before": async (input) => {
|
||||||
|
const status = await sdk("GET", "/budget/status", null);
|
||||||
|
if (!status) return; // fail-open
|
||||||
|
if (status.halt) {
|
||||||
|
throw new Error(
|
||||||
|
`[Halt] tool budget exhausted (${status.total}/${status.halt_threshold}). ` +
|
||||||
|
"Stop now — release the task with unclaim() or i_am_idle().",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status.loop && status.loop_action === "halt") {
|
||||||
|
throw new Error(
|
||||||
|
"[Loop] same tool+args repeated in window — halting. " +
|
||||||
|
"Release the task with unclaim() or stop with i_am_idle().",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tool.execute.after": async (input) => {
|
||||||
|
const raw = String(input?.tool || "");
|
||||||
|
if (!raw) return;
|
||||||
|
await sdk("POST", "/terminal/tool_recorded", { tool: bareVerb(raw) });
|
||||||
|
await sdk("POST", "/budget/tool_called", {
|
||||||
|
tool: bareVerb(raw),
|
||||||
|
args_hash: argsHash(input?.args),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,33 +1,37 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Entrypoint for the roboco-agent-grok image.
|
# Entrypoint for the roboco-agent-grok image (one-shot delivery roles).
|
||||||
#
|
#
|
||||||
# Renders opencode.json from the RoboCo spawn env (OPENAI_* + ROBOCO_*, set by
|
# Renders opencode.json from the RoboCo spawn env (OPENAI_* + ROBOCO_*, set by
|
||||||
# GrokProvider) plus the mounted Claude Code mcp-config.json, then runs opencode
|
# GrokProvider) plus the mounted Claude Code mcp-config.json, starts the
|
||||||
# non-interactively. opencode speaks the OpenAI protocol, so grok-build-0.1 runs
|
# in-container SDK server (parity with the Claude SessionStart sdk-startup-hook),
|
||||||
# natively against api.x.ai/v1 with no shim, while still reaching the RoboCo MCP
|
# then runs opencode non-interactively. opencode speaks the OpenAI protocol, so
|
||||||
# gateway (roboco-flow / roboco-do / ...) translated into opencode's mcp config.
|
# 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
|
set -euo pipefail
|
||||||
|
|
||||||
|
SDK_PORT="${ROBOCO_SDK_PORT:-9000}"
|
||||||
|
SDK_URL="http://localhost:${SDK_PORT}"
|
||||||
|
|
||||||
# Generate opencode.json (provider + model + MCP gateway + permissions +
|
# Generate opencode.json (provider + model + MCP gateway + permissions +
|
||||||
# instructions). Writes to opencode's global config dir by default.
|
# instructions). Writes to opencode's global config dir by default.
|
||||||
python -m roboco.llm.providers.opencode_config
|
python -m roboco.llm.providers.opencode_config
|
||||||
|
|
||||||
# Run the agent. The prompt comes from an env var (never an untrusted argv
|
# --- SDK server bring-up (Claude-parity) ----------------------------------
|
||||||
# positional); `--` separates it from flags so a prompt starting with `--`
|
# The flow/do MCP servers POST /verb/attempted here for the per-verb circuit
|
||||||
# cannot be parsed as CLI options. The model also comes from the rendered
|
# breaker; the budget-feed opencode plugin POSTs /budget/* + /terminal/* here;
|
||||||
# config; --model is passed explicitly as belt-and-suspenders.
|
# the post-exit hook below reads /terminal/status and writes the post-mortem.
|
||||||
#
|
# Bare `python` (the baked venv) — NOT `uv run`, which would re-sync the clone's
|
||||||
# `< /dev/null` is REQUIRED: without a closed stdin, `opencode run` hangs after
|
# drifted lock and stall (the #179 fix the Claude hook needs `--no-sync` for).
|
||||||
# init in a headless / no-TTY environment (it blocks waiting on stdin). Verified
|
if ! curl -sf -m 2 "${SDK_URL}/health" >/dev/null 2>&1; then
|
||||||
# live — closing stdin lets the run proceed to the model call and exit cleanly.
|
nohup python -m roboco.agent_sdk.server >/tmp/sdk-server.log 2>&1 &
|
||||||
#
|
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||||
# Reasoning effort: GrokProvider sets ROBOCO_GROK_VARIANT per role (e.g.
|
if curl -sf -m 2 "${SDK_URL}/health" >/dev/null 2>&1; then break; fi
|
||||||
# "minimal" for coordination/docs roles to cut reasoning cost). Absent =
|
sleep 0.5
|
||||||
# opencode default (full reasoning).
|
done
|
||||||
variant_arg=()
|
|
||||||
if [ -n "${ROBOCO_GROK_VARIANT:-}" ]; then
|
|
||||||
variant_arg=(--variant "$ROBOCO_GROK_VARIANT")
|
|
||||||
fi
|
fi
|
||||||
|
# Zero the budget/terminal counters at the start of the session.
|
||||||
|
curl -sf -m 2 -X POST "${SDK_URL}/budget/reset" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
# Prompt-injection guard (parity with the Claude UserPromptSubmit hook): the
|
# Prompt-injection guard (parity with the Claude UserPromptSubmit hook): the
|
||||||
# task prompt is DATA, not instructions — refuse a poisoned one before it
|
# task prompt is DATA, not instructions — refuse a poisoned one before it
|
||||||
@@ -37,7 +41,54 @@ if ! python -m roboco.agent_sdk.prompt_guard "${ROBOCO_INITIAL_PROMPT:-}"; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
exec opencode run \
|
# Reasoning effort: GrokProvider sets ROBOCO_GROK_VARIANT per role (e.g.
|
||||||
|
# "minimal" for coordination/docs roles to cut reasoning cost). Absent =
|
||||||
|
# opencode default (full reasoning).
|
||||||
|
variant_arg=()
|
||||||
|
if [ -n "${ROBOCO_GROK_VARIANT:-}" ]; then
|
||||||
|
variant_arg=(--variant "$ROBOCO_GROK_VARIANT")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 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. `< /dev/null` is REQUIRED: without a closed
|
||||||
|
# stdin, `opencode run` hangs after init in a headless / no-TTY environment.
|
||||||
|
#
|
||||||
|
# We do NOT `exec`: the script must regain control after opencode exits to run
|
||||||
|
# the post-mortem + silent-exit substitute below (the Claude SessionEnd / Stop
|
||||||
|
# hooks have no opencode equivalent, so the boundary handles them). `set +e`
|
||||||
|
# around the run so a non-zero opencode exit doesn't abort before the post-run
|
||||||
|
# hooks; PIPESTATUS preserves opencode's real code through the `tee`.
|
||||||
|
set +e
|
||||||
|
opencode run \
|
||||||
--model "xai/${ROBOCO_AGENT_MODEL:-grok-build-0.1}" \
|
--model "xai/${ROBOCO_AGENT_MODEL:-grok-build-0.1}" \
|
||||||
"${variant_arg[@]}" \
|
"${variant_arg[@]}" \
|
||||||
-- "${ROBOCO_INITIAL_PROMPT:-}" < /dev/null
|
-- "${ROBOCO_INITIAL_PROMPT:-}" < /dev/null
|
||||||
|
run_rc=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# --- Post-run hooks (Claude SessionEnd + Stop parity) ----------------------
|
||||||
|
# Read terminal state once, then (a) write a post-mortem journal entry and
|
||||||
|
# (b) if the agent exited WITHOUT a terminal verb (i_am_idle / i_am_done /
|
||||||
|
# pass / fail / ...), auto-substitute the task so it is not left stuck in
|
||||||
|
# claimed/in_progress for a human to hand-unstick. Best-effort; never change
|
||||||
|
# the exit code the orchestrator observes.
|
||||||
|
terminal=$(curl -sf -m 2 "${SDK_URL}/terminal/status" 2>/dev/null || echo "")
|
||||||
|
last_tool="null"
|
||||||
|
had_terminal="false"
|
||||||
|
if [ -n "$terminal" ]; then
|
||||||
|
last_tool=$(echo "$terminal" | jq -r '.last_tool // "null"' 2>/dev/null || echo "null")
|
||||||
|
had_terminal=$(echo "$terminal" | jq -r '.had_terminal_recently // false' 2>/dev/null || echo "false")
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl -sf -m 3 -X POST "${SDK_URL}/journal/post_mortem" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"terminal_tool\":\"${last_tool}\",\"reason\":\"session_end\"}" \
|
||||||
|
>/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
if [ "$had_terminal" != "true" ]; then
|
||||||
|
curl -sf -m 3 -X POST "${SDK_URL}/terminal/force_substitute" >/dev/null 2>&1 || true
|
||||||
|
echo "[grok] exited without a terminal verb (last tool: ${last_tool}) — auto-substituted." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit "$run_rc"
|
||||||
|
|||||||
@@ -21,15 +21,23 @@ Config shape per opencode docs (https://opencode.ai/docs/config):
|
|||||||
review). The request/stream timeouts below are the defence-in-depth backstop.
|
review). The request/stream timeouts below are the defence-in-depth backstop.
|
||||||
|
|
||||||
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``); usage/cost is captured from opencode's SQLite store at
|
(``tool.execute.before``); the per-session budget / loop / terminal-verb
|
||||||
finalize and bounded by the orchestrator cost watchdog
|
counters and the per-verb circuit breaker are restored by starting the same
|
||||||
(``ROBOCO_GROK_MAX_COST_USD``, which also catches runaway-loop burn); and the
|
in-container SDK server the Claude path runs (the grok entrypoint launches
|
||||||
prompt-injection guard is recreated at RoboCo's input boundary
|
``roboco.agent_sdk.server``) and feeding it from ``budget-feed.js``
|
||||||
(``roboco.agent_sdk.prompt_guard`` — the driver scans interactive turns, the
|
(``tool.execute.{before,after}``); usage/cost is captured from opencode's SQLite
|
||||||
entrypoint scans the one-shot task prompt). The only Claude hook without an
|
store at finalize and bounded by the orchestrator cost watchdog
|
||||||
opencode equivalent is the stop-guard (terminal-verb enforcement; opencode's
|
(``ROBOCO_GROK_MAX_COST_USD``); the prompt-injection guard is recreated at
|
||||||
stop events are observe-only) — a workflow nicety, not a security control.
|
RoboCo's input boundary (``roboco.agent_sdk.prompt_guard``); and the SessionEnd
|
||||||
``bash`` permission stays operator-tunable (``ROBOCO_GROK_BASH_PERMISSION``).
|
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
|
from __future__ import annotations
|
||||||
@@ -49,8 +57,29 @@ _PROVIDER_ID = "xai"
|
|||||||
_PROVIDER_NPM = "@ai-sdk/openai"
|
_PROVIDER_NPM = "@ai-sdk/openai"
|
||||||
|
|
||||||
# Plugins baked into the roboco-agent-grok image (see docker/agent-grok.Dockerfile).
|
# 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.
|
# secret-scrub ports the bash-guard deny rules to opencode's tool.execute.before;
|
||||||
_PLUGINS = ["/app/opencode-plugins/secret-scrub.js"]
|
# 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()]
|
||||||
|
|
||||||
|
|
||||||
# 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
|
||||||
@@ -141,9 +170,11 @@ def build_opencode_config(
|
|||||||
*,
|
*,
|
||||||
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 full ``opencode.json`` dict for a Grok agent."""
|
||||||
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": {
|
"provider": {
|
||||||
@@ -171,8 +202,9 @@ def build_opencode_config(
|
|||||||
"external_directory": guards.external_directory_permission,
|
"external_directory": guards.external_directory_permission,
|
||||||
},
|
},
|
||||||
"instructions": instruction_paths,
|
"instructions": instruction_paths,
|
||||||
# Command guard / secret-scrub (bash-guard parity). Baked into the image.
|
# secret-scrub (bash-guard parity) + budget-feed (SDK budget/loop feed),
|
||||||
"plugin": list(_PLUGINS),
|
# 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.
|
||||||
@@ -207,6 +239,7 @@ def main() -> int:
|
|||||||
)
|
)
|
||||||
guards = OpencodeGuards(
|
guards = OpencodeGuards(
|
||||||
bash_permission=os.environ.get("ROBOCO_GROK_BASH_PERMISSION", "allow"),
|
bash_permission=os.environ.get("ROBOCO_GROK_BASH_PERMISSION", "allow"),
|
||||||
|
edit_permission=os.environ.get("ROBOCO_GROK_EDIT_PERMISSION", "allow"),
|
||||||
external_directory_permission=os.environ.get(
|
external_directory_permission=os.environ.get(
|
||||||
"ROBOCO_GROK_EXTERNAL_DIR_PERMISSION", "allow"
|
"ROBOCO_GROK_EXTERNAL_DIR_PERMISSION", "allow"
|
||||||
),
|
),
|
||||||
@@ -227,6 +260,7 @@ def main() -> int:
|
|||||||
target,
|
target,
|
||||||
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)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from roboco.llm.providers.opencode_config import (
|
|||||||
OpencodeGuards,
|
OpencodeGuards,
|
||||||
XaiTarget,
|
XaiTarget,
|
||||||
_env_int,
|
_env_int,
|
||||||
|
_extra_plugins,
|
||||||
build_opencode_config,
|
build_opencode_config,
|
||||||
translate_mcp_servers,
|
translate_mcp_servers,
|
||||||
)
|
)
|
||||||
@@ -88,8 +89,52 @@ def test_build_opencode_config_provider_and_model() -> None:
|
|||||||
# 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 is wired in by default.
|
# The secret-scrub command guard + the SDK budget-feed are wired in by default.
|
||||||
assert cfg["plugin"] == ["/app/opencode-plugins/secret-scrub.js"]
|
assert cfg["plugin"] == [
|
||||||
|
"/app/opencode-plugins/secret-scrub.js",
|
||||||
|
"/app/opencode-plugins/budget-feed.js",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_opencode_config_appends_extra_plugins() -> None:
|
||||||
|
# Per-image role tool plugins (secretary directive tools, intake propose_draft)
|
||||||
|
# append AFTER the baked defaults so the role-scoped tools load too.
|
||||||
|
cfg = build_opencode_config(
|
||||||
|
_MCP,
|
||||||
|
_TARGET,
|
||||||
|
instruction_paths=[],
|
||||||
|
extra_plugins=["/app/opencode-plugins/secretary-tools.js"],
|
||||||
|
)
|
||||||
|
assert cfg["plugin"] == [
|
||||||
|
"/app/opencode-plugins/secret-scrub.js",
|
||||||
|
"/app/opencode-plugins/budget-feed.js",
|
||||||
|
"/app/opencode-plugins/secretary-tools.js",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_extra_plugins_reads_pathsep_env() -> None:
|
||||||
|
with patch.dict(os.environ, {}, clear=True):
|
||||||
|
assert _extra_plugins() == []
|
||||||
|
joined = os.pathsep.join(["/a/one.js", "/b/two.js"])
|
||||||
|
with patch.dict(os.environ, {"ROBOCO_OPENCODE_EXTRA_PLUGINS": joined}):
|
||||||
|
assert _extra_plugins() == ["/a/one.js", "/b/two.js"]
|
||||||
|
# Blank entries are dropped (a trailing pathsep or empty override is benign).
|
||||||
|
with patch.dict(
|
||||||
|
os.environ, {"ROBOCO_OPENCODE_EXTRA_PLUGINS": f"/a/one.js{os.pathsep} "}
|
||||||
|
):
|
||||||
|
assert _extra_plugins() == ["/a/one.js"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_opencode_config_edit_permission_is_tunable() -> None:
|
||||||
|
# Read-only roles (qa / pr_reviewer / auditor / PMs / board) get edit=deny so
|
||||||
|
# a Grok agent can't write code on a role that must never touch the tree.
|
||||||
|
cfg = build_opencode_config(
|
||||||
|
{},
|
||||||
|
_TARGET,
|
||||||
|
instruction_paths=[],
|
||||||
|
guards=OpencodeGuards(edit_permission="deny"),
|
||||||
|
)
|
||||||
|
assert cfg["permission"]["edit"] == "deny"
|
||||||
|
|
||||||
|
|
||||||
def test_build_opencode_config_bash_permission_is_tunable() -> None:
|
def test_build_opencode_config_bash_permission_is_tunable() -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user