mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(grok): convert interactive intake/secretary to the grok CLI; delete opencode
Move the last Grok runtime off opencode onto xAI's official `grok` CLI, for full parity with the Claude path. The intake/secretary chat now runs per-turn headless `grok -p` invocations that resume one session id (proven live: context carries across runs), with streaming-json deltas mapped to the existing panel StreamChunk kinds — the IntakeDriver loop, message source, relay, and idle reaper are reused unchanged; only the SessionFactory differs (GrokCliSession replaces the opencode-serve session). - GrokCliSession + a pure, unit-tested streaming-json -> StreamChunk assembler (thought coalesced to one block, text streamed live, end captures the session id for -r, fenced-draft fallback, clear errors incl. rate-limit). - intake propose_draft and secretary read_company_state/read_task/submit_directive are now FastMCP servers (roboco-intake / roboco-secretary) wired into ~/.grok/config.toml, launched via `uv run --directory /app` to resolve the installed package. The secretary tools reuse the shared backend helpers. - Orchestrator: interactive spawn mounts the subscription auth + per-agent usage dir (no metered xAI key, no permission env — grok flags carry per-role perms); usage/cost now read a captured usage.json (drop the opencode.db reader, the _opencode_db_path/_grok_usage_from_opencode methods, and the cost-cap's opencode read). hosts["opencode"] -> hosts["grok_usage"]; OPENCODE_DATA_DIR -> GROK_USAGE_DATA_DIR. - Fix one-shot usage capture: `-s` does not pin the session id (grok generates its own), so the entrypoint now reads the real id back from the JSON run log and the reader uses it; usage is captured per-turn on the interactive path. - Delete the opencode layer: opencode_config/opencode_usage/opencode_session, the docker/grok/*.js plugins, the old one-shot entrypoint, and their tests. - Compose (all three files), .env.example, and stale comments updated to the grok-CLI runtime; add the SuperGrok auth mount + grok-usage dir. Gate green: ruff, mypy (296 files), xenon, tests. NAS build/verify pending.
This commit is contained in:
@@ -1,28 +1,19 @@
|
||||
# GROK Intake (Prompter) Agent — interactive opencode-serve session on Grok.
|
||||
# GROK Intake (Prompter) Agent — interactive grok-CLI session on Grok.
|
||||
# =============================================================================
|
||||
# The Grok analogue of agent-prompter. Unlike the one-shot Grok runtime (a
|
||||
# single `opencode run` that exits), this holds a PERSISTENT `opencode serve`
|
||||
# session open, receives the human's messages over HTTP (POST /turn on :9000),
|
||||
# and streams each reply back to the panel via the relay. Builds on the Grok
|
||||
# runtime image (opencode + @ai-sdk/openai + the secret-scrub plugin); the
|
||||
# driver renders opencode.json from the spawn env, then drives the session.
|
||||
# The Grok analogue of agent-prompter. Unlike the one-shot Grok runtime (a single
|
||||
# `grok -p` that exits), this holds a PERSISTENT conversation: it receives the
|
||||
# human's messages over HTTP (POST /turn on :9000) and, per turn, runs a headless
|
||||
# `grok -p` that resumes one session id, streaming each reply back to the panel
|
||||
# via the relay (see roboco.agent_sdk.grok_intake_main + grok_cli_session). The
|
||||
# intake `propose_draft` tool is wired as the roboco-intake MCP server (rendered
|
||||
# into ~/.grok/config.toml by the driver). Builds on the Grok runtime image
|
||||
# (grok CLI + the roboco venv).
|
||||
# =============================================================================
|
||||
|
||||
FROM roboco-agent-grok
|
||||
|
||||
USER root
|
||||
|
||||
# The intake propose_draft tool plugin (the model calls it; the driver turns the
|
||||
# call into the panel's draft card), baked into the auto-discovery dir so only
|
||||
# the intake image carries it. opencode registers tools from this directory, not
|
||||
# from a config `plugin:`-array path (verified live).
|
||||
COPY docker/grok/intake-tools.js /home/agent/.config/opencode/plugin/intake-tools.js
|
||||
RUN chown agent:agent /home/agent/.config/opencode/plugin/intake-tools.js
|
||||
|
||||
USER agent
|
||||
|
||||
LABEL role="grok-prompter"
|
||||
LABEL description="Intake interviewer on Grok — a long-lived opencode serve session driven by the panel"
|
||||
LABEL description="Intake interviewer on Grok — a panel-driven grok-CLI conversation"
|
||||
|
||||
# The in-container receiver the orchestrator delivers the human's turns to.
|
||||
EXPOSE 9000
|
||||
|
||||
@@ -1,29 +1,19 @@
|
||||
# GROK Secretary Agent — interactive opencode-serve session on Grok.
|
||||
# GROK Secretary Agent — interactive grok-CLI session on Grok.
|
||||
# =============================================================================
|
||||
# The Grok analogue of agent-secretary. Holds a PERSISTENT `opencode serve`
|
||||
# session open, receives the CEO's messages over HTTP (POST /turn on :9000), and
|
||||
# streams each reply back to the panel via the relay. The Secretary's CEO-
|
||||
# authority tools (read_company_state / read_task / submit_directive) are
|
||||
# registered as opencode tools by the secretary-tools.js plugin, which calls
|
||||
# /api/secretary/* with the container's HMAC agent token. Builds on the Grok
|
||||
# runtime image; the driver renders opencode.json from the spawn env first.
|
||||
# The Grok analogue of agent-secretary. Holds a PERSISTENT conversation: receives
|
||||
# the CEO's messages over HTTP (POST /turn on :9000) and, per turn, runs a
|
||||
# headless `grok -p` that resumes one session id, streaming each reply back to the
|
||||
# panel via the relay. The Secretary's CEO-authority tools (read_company_state /
|
||||
# read_task / submit_directive) are wired as the roboco-secretary MCP server
|
||||
# (rendered into ~/.grok/config.toml by the driver), which calls /api/secretary/*
|
||||
# with the container's HMAC agent token. Builds on the Grok runtime image
|
||||
# (grok CLI + the roboco venv).
|
||||
# =============================================================================
|
||||
|
||||
FROM roboco-agent-grok
|
||||
|
||||
USER root
|
||||
|
||||
# The CEO-authority tool plugin (read_company_state / read_task / submit_directive),
|
||||
# baked into the auto-discovery dir so ONLY the Secretary image carries it (no
|
||||
# other role gets CEO authority). opencode registers it from this directory; a
|
||||
# config `plugin:`-array path would not register its tools (verified live).
|
||||
COPY docker/grok/secretary-tools.js /home/agent/.config/opencode/plugin/secretary-tools.js
|
||||
RUN chown agent:agent /home/agent/.config/opencode/plugin/secretary-tools.js
|
||||
|
||||
USER agent
|
||||
|
||||
LABEL role="grok-secretary"
|
||||
LABEL description="Secretary on Grok — a long-lived opencode serve session driven by the panel"
|
||||
LABEL description="Secretary on Grok — a panel-driven grok-CLI conversation"
|
||||
|
||||
# The in-container receiver the orchestrator delivers the CEO's turns to.
|
||||
EXPOSE 9000
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
// 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 policy: the `after` POSTs never block (recording can't risk spend). The
|
||||
// `before` gate fails OPEN by default, but fails CLOSED when ROBOCO_BUDGET_ENFORCE=1
|
||||
// (set by the one-shot entrypoint, which always starts the SDK budget server) and
|
||||
// the budget server is unreachable — an unenforceable cost cap on a task agent is
|
||||
// the one case worth halting for. Interactive serve images (intake / secretary)
|
||||
// own :9000 for the human-turn receiver, run NO SDK budget server, and set no
|
||||
// ENFORCE flag, so their tool calls always proceed (these POSTs 404 there).
|
||||
|
||||
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.
|
||||
// Verified live: opencode delivers MCP tools as "roboco-flow_<verb>" (underscore);
|
||||
// the "." form and an mcp__ prefix are still handled defensively.
|
||||
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;
|
||||
}
|
||||
|
||||
// Release/escape verbs must ALWAYS be allowed through the before-gate. A halt
|
||||
// (budget, loop, or fail-closed) that also blocks these traps the agent: it can
|
||||
// neither continue nor stop, so it flails — and every blocked retry is another
|
||||
// billed model turn. Letting i_am_idle / unclaim / i_am_blocked through is the
|
||||
// only way a halted agent can exit cleanly.
|
||||
const RELEASE_VERBS = new Set(["i_am_idle", "unclaim", "i_am_blocked"]);
|
||||
|
||||
// Named export (opencode's plugin convention) + baked into the plugin
|
||||
// auto-discovery dir (~/.config/opencode/plugin/) at image build — the simplest
|
||||
// registration route (no config `plugin:` path needed).
|
||||
export const RobocoBudgetFeed = async () => {
|
||||
return {
|
||||
"tool.execute.before": async (input) => {
|
||||
// Escape hatches always pass — a halted agent must be able to stop.
|
||||
if (RELEASE_VERBS.has(bareVerb(String(input?.tool || "")))) return;
|
||||
const status = await sdk("GET", "/budget/status", null);
|
||||
if (!status) {
|
||||
// One-shot delivery agents MUST have the in-container SDK budget server
|
||||
// (the entrypoint starts it and exports ENFORCE=1). A missing signal
|
||||
// there means the cost cap is unenforceable — fail CLOSED to stop an
|
||||
// uncapped burn. Interactive serve agents set no flag → fail open.
|
||||
if (process.env.ROBOCO_BUDGET_ENFORCE === "1") {
|
||||
throw new Error(
|
||||
"[Halt] budget server unreachable — failing closed to prevent " +
|
||||
"uncapped token spend. Stop now with i_am_idle() or unclaim().",
|
||||
);
|
||||
}
|
||||
return; // fail-open (no budget server expected for this role)
|
||||
}
|
||||
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,70 +0,0 @@
|
||||
// opencode plugin — the Intake interviewer's propose_draft tool, on Grok.
|
||||
//
|
||||
// The model calls propose_draft once the task spec is ready; this delivers the
|
||||
// draft to the panel's reviewable draft card.
|
||||
//
|
||||
// WHY IT POSTS DIRECTLY (not via the driver): opencode's synchronous serve reply
|
||||
// (POST /session/:id/message) returns only the final assistant text + step
|
||||
// markers — NOT the tool-CALL parts. So OpencodeServeSession cannot intercept
|
||||
// this call to emit a `draft` chunk (verified live: a propose_draft call comes
|
||||
// back as parts=[step-start, text, step-finish], no tool part). Instead the tool
|
||||
// POSTs the draft straight to the prompter-live relay — the same
|
||||
// /api/prompter/live/{session}/events endpoint the driver's relay sink uses — so
|
||||
// the panel renders the card regardless. (The Claude intake path differs: the
|
||||
// Claude SDK DOES expose the tool-use block, so its driver intercepts it.)
|
||||
//
|
||||
// Loaded from the plugin auto-discovery dir (~/.config/opencode/plugin/), baked
|
||||
// into the grok-prompter image only (the one-shot delivery roles never draft).
|
||||
// The container provides ROBOCO_API_URL + ROBOCO_PROMPTER_SESSION_ID.
|
||||
|
||||
import { tool } from "@opencode-ai/plugin";
|
||||
|
||||
const API_BASE = (
|
||||
process.env.ROBOCO_API_URL || "http://roboco-orchestrator:8000"
|
||||
).replace(/\/+$/, "");
|
||||
|
||||
export const RobocoIntakeTools = async () => ({
|
||||
tool: {
|
||||
propose_draft: tool({
|
||||
description:
|
||||
"Submit the finished task draft for the human to review and confirm. " +
|
||||
"Call this once the spec is complete. Pass a JSON object: title, " +
|
||||
"objective, what_this_builds[], the_work[] ({team, summary, items}), " +
|
||||
"notes[], acceptance_criteria[], team, scale, task_type, nature, " +
|
||||
"estimated_complexity, priority.",
|
||||
args: {
|
||||
draft: tool.schema
|
||||
.record(tool.schema.string(), tool.schema.any())
|
||||
.describe("The task draft object"),
|
||||
},
|
||||
async execute(args) {
|
||||
const session = process.env.ROBOCO_PROMPTER_SESSION_ID || "";
|
||||
if (!session) {
|
||||
return "No live session id (ROBOCO_PROMPTER_SESSION_ID) — cannot surface the draft.";
|
||||
}
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/prompter/live/${encodeURIComponent(session)}/events`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
kind: "draft",
|
||||
text: "",
|
||||
tool: "propose_draft",
|
||||
data: args.draft || {},
|
||||
}),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
return `Draft relay returned HTTP ${res.status}; the human may not see the card.`;
|
||||
}
|
||||
} catch (e) {
|
||||
return "Could not submit the draft to the panel: " + String(e);
|
||||
}
|
||||
return "Draft submitted — the human can review it in the panel.";
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
@@ -1,192 +0,0 @@
|
||||
// opencode plugin — command guard / secret-scrub for RoboCo Grok agents.
|
||||
//
|
||||
// Ports the security-critical deny rules from docker/scripts/bash-guard-hook.sh
|
||||
// (the Claude Code PreToolUse guard) to opencode's `tool.execute.before` hook.
|
||||
// Those rules are Claude Code hooks and do NOT transfer to the opencode runtime,
|
||||
// so a Grok agent would otherwise run bash unguarded — this restores parity.
|
||||
//
|
||||
// Mechanism (confirmed by opencode's own env-protection plugin example):
|
||||
// throwing inside `tool.execute.before` denies the tool call. For `bash` the
|
||||
// command is `output.args.command`; for `read`/`edit` the path is
|
||||
// `output.args.filePath`.
|
||||
//
|
||||
// Baked into the plugin auto-discovery dir (~/.config/opencode/plugin/) at image
|
||||
// build (named export, opencode's convention) — the same route as budget-feed.
|
||||
// The agent's bash permission is a second gate via ROBOCO_GROK_BASH_PERMISSION.
|
||||
//
|
||||
// STATUS: the plugin loads in the live runtime (same auto-discovery dir as the
|
||||
// live-confirmed budget-feed), but the deny-on-match path has not yet blocked a
|
||||
// real command on the NAS — confirm before trusting it as the sole bash gate.
|
||||
// Deny-on-match is fail-closed: a false positive blocks a legitimate command
|
||||
// (annoying, safe) rather than letting a dangerous one through.
|
||||
|
||||
const CREDENTIAL_FILE =
|
||||
/(\.git\/config|\.gitconfig|\.git-credentials|\.netrc|\.ssh\/|id_rsa|id_ed25519|id_ecdsa|known_hosts)/;
|
||||
|
||||
// Secret-bearing files for the source / encode / interpreter rules. Wider than
|
||||
// CREDENTIAL_FILE (which also gates Read/Edit *paths*, so it must NOT include
|
||||
// .env lest it block reading .env.example): a bash command that READS these is
|
||||
// exfiltration. Mirrors the file set in bash-guard-hook.sh's source/interpreter
|
||||
// rules.
|
||||
const SECRET_FILE =
|
||||
/(\.env\b|\/etc\/environment|\.git-credentials|\.netrc|\.git\/config|\.gitconfig|\/proc\/[^\s]*environ|\.profile|\.bashrc|\.zshrc|id_rsa|id_ed25519|id_ecdsa|\.ssh\/)/;
|
||||
|
||||
// git network/auth/branch-mutating ops — run against the SKELETONIZED command
|
||||
// (see gitSkeleton) so a heredoc/echo that merely documents `git push` is not
|
||||
// mistaken for invoking it. Mirrors bash-guard-hook.sh's git-ops rule.
|
||||
const GIT_OPS =
|
||||
/(^|[\s;&|])git\s+(fetch|pull|push|clone|remote|ls-remote|checkout|commit|merge|rebase|reset|cherry-pick|revert|tag\s+-d|update-ref|reflog\s+delete)/;
|
||||
|
||||
const INTERNAL_HOST =
|
||||
/((https?|wss?):\/\/)?\/?(roboco-[a-z0-9_-]+|localhost|127\.0\.0\.1|0\.0\.0\.0)[:/]/;
|
||||
|
||||
const HTTP_CLIENT_LIB =
|
||||
/(httpx|requests|urllib|aiohttp|http\.client|httplib|net\/http|net::http|node-fetch|axios|xmlhttprequest|websocket|fetch\s*\()/;
|
||||
|
||||
// Each check takes the lowercased bash command and returns a deny reason, or
|
||||
// null to allow. Mirrors the categories in bash-guard-hook.sh.
|
||||
const BASH_CHECKS = [
|
||||
// (git-ops is checked first in denyBash, on the skeletonized command.)
|
||||
(low) =>
|
||||
CREDENTIAL_FILE.test(low)
|
||||
? "command references a credential file or SSH key — the PAT is injected subprocess-side by the MCP layer, never read from these files."
|
||||
: null,
|
||||
(low) =>
|
||||
/(^|[\s;&|])(source|\.)\s+[^|;&]*/.test(low) && SECRET_FILE.test(low)
|
||||
? "sourcing a credential-bearing file (.env / .git-credentials / .netrc / ...) exposes secrets in the current shell."
|
||||
: null,
|
||||
(low) =>
|
||||
/(^|[\s;&|])(python3?|perl|node|ruby|awk|sed)\s+[^|;&]*-[ce]\s/.test(low) &&
|
||||
SECRET_FILE.test(low)
|
||||
? "interpreter one-liner reads a credential file — ask for the value you need via the task description."
|
||||
: null,
|
||||
(low) =>
|
||||
/\/proc\/(self|\d+|\$\$)\/(environ|cmdline|cwd|exe)/.test(low)
|
||||
? "reading /proc/*/environ or /proc/*/cmdline can leak credentials."
|
||||
: null,
|
||||
(low) =>
|
||||
/(^|[\s;&|])(curl|wget|http|https|httpie)\s[^|]*(github\.com|api\.github\.com)/.test(
|
||||
low,
|
||||
)
|
||||
? "direct GitHub HTTP calls bypass the PAT handler — use the role-appropriate MCP verb."
|
||||
: null,
|
||||
(low) =>
|
||||
/(^|[\s;&|])(curl|wget|http|https|httpie)\s/.test(low) && INTERNAL_HOST.test(low)
|
||||
? "internal API calls bypass the gateway — use the MCP verbs (roboco-flow / roboco-do / roboco-git-readonly / roboco-optimal)."
|
||||
: null,
|
||||
(low) =>
|
||||
HTTP_CLIENT_LIB.test(low) && INTERNAL_HOST.test(low)
|
||||
? "reaching an internal host via an HTTP client bypasses the gateway, role manifest, tracing and auth (and can forge X-Agent-* headers). Use your MCP verbs."
|
||||
: null,
|
||||
(low) =>
|
||||
/(python3?|uv\s+run|poetry\s+run|pipenv\s+run|pdm\s+run|hatch\s+run)/.test(low) &&
|
||||
/(import\s+roboco|from\s+roboco|-m\s+roboco|roboco\.(mcp|services|runtime|foundation|api|enforcement)\b)/.test(
|
||||
low,
|
||||
)
|
||||
? "importing or running roboco.* internals from the shell bypasses the MCP role manifest, tracing and auth. Use your role's MCP verbs."
|
||||
: null,
|
||||
(low) =>
|
||||
/(^|[\s;&|]|env\s+|export\s+)roboco_agent_id\s*=/.test(low)
|
||||
? "ROBOCO_AGENT_ID is your injected identity — overriding it forges another agent's identity. Never set or export it."
|
||||
: null,
|
||||
(low) =>
|
||||
/(^|[\s;&|])(env|printenv)([\s]|$)/.test(low) &&
|
||||
!/(^|[\s;&|])env\s+(-i|[a-z_][a-z0-9_]*=)/.test(low)
|
||||
? "env / printenv can leak secrets. Ask for the specific value you need via the task description."
|
||||
: null,
|
||||
(low) =>
|
||||
/(^|[\s;&|])set([\s]*$|[\s]*[|;&])/.test(low) ||
|
||||
/(^|[\s;&|])(declare|typeset)\s+-[a-z]*[xp]/.test(low) ||
|
||||
/(^|[\s;&|])export\s+-p([\s]|$)/.test(low) ||
|
||||
/(^|[\s;&|])compgen\s+-[a-z]*[ve]/.test(low)
|
||||
? "shell built-ins that dump variables/exports can leak credentials."
|
||||
: null,
|
||||
(low) =>
|
||||
/(^|[\s;&|])(base64|od|xxd|hexdump|strings|uuencode)\s[^|;&]*(\.env|\.git\/config|\.gitconfig|\.git-credentials|\.netrc|\.ssh\/|id_rsa|id_ed25519)/.test(
|
||||
low,
|
||||
)
|
||||
? "encoding/inspecting a credential file is still exfiltration."
|
||||
: null,
|
||||
(low) =>
|
||||
/(^|[\s;&|])rm\s[^|;&]*-[a-z]*[rf][a-z]*\s/.test(low) &&
|
||||
/(^|[\s;&|])rm\s[^|;&]*(\/app($|[\s/])|\/root|\/etc|\/var|\/usr|\/bin|\/sbin|\/lib|\/home|\s\/\s*(;|\||&|$))/.test(
|
||||
low,
|
||||
)
|
||||
? "rm on a system path. Operate inside your own workspace only."
|
||||
: null,
|
||||
];
|
||||
|
||||
// Tools that take a file path we must keep away from credential files.
|
||||
const PATH_TOOLS = new Set(["read", "edit", "write"]);
|
||||
|
||||
// Strip heredoc bodies and echo/printf literal args BEFORE the git-ops check —
|
||||
// those are data the shell writes to a file, not commands it runs, so a
|
||||
// README/heredoc that documents `git push` must not be mistaken for invoking
|
||||
// it. Quoted args to an interpreter (`bash -c "... && git fetch"`) ARE executed
|
||||
// and are not echo/printf/heredoc bodies, so they survive. Mirrors the
|
||||
// git_skel logic in bash-guard-hook.sh; every other rule sees the full command.
|
||||
function gitSkeleton(command) {
|
||||
const lines = String(command || "").split("\n");
|
||||
const opener = /<<-?\s*[^\sA-Za-z_]*([A-Za-z_]\w*)/;
|
||||
const kept = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
kept.push(lines[i]);
|
||||
const m = opener.exec(lines[i]);
|
||||
if (m) {
|
||||
const delim = m[1];
|
||||
const dash = lines[i].includes("<<-");
|
||||
i++;
|
||||
while (i < lines.length) {
|
||||
const body = lines[i];
|
||||
const cand = dash ? body.trim() : body;
|
||||
if (cand === delim) {
|
||||
kept.push(body);
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return kept
|
||||
.join("\n")
|
||||
.replace(/(^|[\n;&|]|&&|\|\|)\s*(echo|printf)\b[^\n;&|]*/g, "$1");
|
||||
}
|
||||
|
||||
function denyBash(command) {
|
||||
const low = String(command || "").toLowerCase();
|
||||
if (!low) return null;
|
||||
if (GIT_OPS.test(gitSkeleton(command).toLowerCase())) {
|
||||
return "shell git for network/auth/branch-mutating ops is blocked — use your role's MCP verb (commit, complete, i_am_done, ...).";
|
||||
}
|
||||
for (const check of BASH_CHECKS) {
|
||||
const reason = check(low);
|
||||
if (reason) return reason;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Named export (opencode's plugin convention) + loaded from opencode's plugin
|
||||
// auto-discovery dir (~/.config/opencode/plugin/), where it's baked at image
|
||||
// build — the simplest registration route (no config `plugin:` path needed).
|
||||
// Hook firing verified live against grok-build-0.1.
|
||||
export const RobocoSecretScrub = async () => {
|
||||
return {
|
||||
"tool.execute.before": async (input, output) => {
|
||||
const tool = input?.tool;
|
||||
const args = output?.args || {};
|
||||
if (tool === "bash") {
|
||||
const reason = denyBash(args.command);
|
||||
if (reason) throw new Error(`Denied by roboco secret-scrub: ${reason}`);
|
||||
return;
|
||||
}
|
||||
if (PATH_TOOLS.has(tool)) {
|
||||
const path = String(args.filePath || args.path || "").toLowerCase();
|
||||
if (path && CREDENTIAL_FILE.test(path)) {
|
||||
throw new Error(
|
||||
"Denied by roboco secret-scrub: access to a credential file / SSH key is blocked.",
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,118 +0,0 @@
|
||||
// opencode plugin — the Secretary's CEO-authority tools, on Grok.
|
||||
//
|
||||
// Parity with the Claude Secretary's SDK tools (roboco.agent_sdk.secretary_driver
|
||||
// .build_secretary_options): read_company_state / read_task / submit_directive,
|
||||
// each calling the backend /api/secretary/* routes with the container's HMAC
|
||||
// agent token. Without these the Grok Secretary can chat but cannot read company
|
||||
// state or act on a CEO directive — the integration blocker.
|
||||
//
|
||||
// Loaded ONLY into the roboco-agent-grok-secretary image via
|
||||
// ROBOCO_OPENCODE_EXTRA_PLUGINS (so no other role gets CEO authority). The
|
||||
// container already carries ROBOCO_AGENT_TOKEN / ROBOCO_API_URL / ROBOCO_AGENT_ID
|
||||
// / ROBOCO_AGENT_ROLE (set by the orchestrator's _build_secretary_run_cmd), so
|
||||
// the auth substrate matches the one-shot Grok path exactly.
|
||||
//
|
||||
// The backend gate-list queues high-impact directive kinds (charter,
|
||||
// control_task, approve_pitch, announce) for the CEO's explicit confirmation and
|
||||
// runs relay_message directly — that policy lives server-side; this plugin only
|
||||
// forwards the call. Each tool returns the backend JSON as a string the model
|
||||
// reads back (mirrors secretary_driver._text_result).
|
||||
//
|
||||
// Verified live on the NAS: the @opencode-ai/plugin tool-registration path
|
||||
// round-trips against a live opencode serve + grok-build-0.1 — a directive
|
||||
// reaches the backend with the HMAC token and the JSON result returns to the model.
|
||||
|
||||
import { tool } from "@opencode-ai/plugin";
|
||||
|
||||
const API_BASE = (
|
||||
process.env.ROBOCO_API_URL || "http://roboco-orchestrator:8000"
|
||||
).replace(/\/+$/, "");
|
||||
const TIMEOUT_MS = 30000;
|
||||
|
||||
function headers() {
|
||||
const h = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Agent-ID": process.env.ROBOCO_AGENT_ID || "",
|
||||
"X-Agent-Role": process.env.ROBOCO_AGENT_ROLE || "secretary",
|
||||
};
|
||||
const token = process.env.ROBOCO_AGENT_TOKEN;
|
||||
if (token) h["X-Agent-Token"] = token;
|
||||
return h;
|
||||
}
|
||||
|
||||
// Call /api/secretary{path}; never throw — a failure becomes an {error,...}
|
||||
// object the model can read and report, exactly like secretary_driver._call_backend.
|
||||
async function callBackend(method, path, body) {
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`${API_BASE}/api/secretary${path}`, {
|
||||
method,
|
||||
headers: headers(),
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
} catch (e) {
|
||||
return { error: "request_failed", detail: String(e) };
|
||||
}
|
||||
let data;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch {
|
||||
data = { detail: await res.text().catch(() => "") };
|
||||
}
|
||||
if (!res.ok) return { error: `http_${res.status}`, detail: data };
|
||||
return data;
|
||||
}
|
||||
|
||||
const asText = (data) => JSON.stringify(data);
|
||||
|
||||
// Named export (opencode's plugin convention) + baked into the plugin
|
||||
// auto-discovery dir (~/.config/opencode/plugin/) at image build. Verified live
|
||||
// against grok-build-0.1: the model called read_company_state + submit_directive
|
||||
// and the backend received both requests with the X-Agent-Token.
|
||||
export const RobocoSecretaryTools = async () => ({
|
||||
tool: {
|
||||
read_company_state: tool({
|
||||
description:
|
||||
"Read a compact snapshot of company state: the charter (goals), task " +
|
||||
"counts by status, pending pitches, and any directives awaiting the " +
|
||||
"CEO's confirmation.",
|
||||
args: {},
|
||||
async execute() {
|
||||
return asText(await callBackend("GET", "/state"));
|
||||
},
|
||||
}),
|
||||
read_task: tool({
|
||||
description: "Read one task's detail by its id.",
|
||||
args: { task_id: tool.schema.string().describe("The task id") },
|
||||
async execute(args) {
|
||||
const id = encodeURIComponent(String(args.task_id));
|
||||
return asText(await callBackend("GET", `/tasks/${id}`));
|
||||
},
|
||||
}),
|
||||
submit_directive: tool({
|
||||
description:
|
||||
"Act on the CEO's command. 'kind' is one of: relay_message " +
|
||||
"(payload: channel, text), update_charter (payload: charter), " +
|
||||
"control_task (payload: task_id, action[start|cancel|override], " +
|
||||
"status?), approve_pitch (payload: pitch_id, notes?), announce " +
|
||||
"(payload: text). High-impact kinds (charter, control_task, " +
|
||||
"approve_pitch, announce) are queued for the CEO's explicit " +
|
||||
"confirmation; relay_message runs directly.",
|
||||
args: {
|
||||
kind: tool.schema.string().describe("The directive kind"),
|
||||
payload: tool.schema
|
||||
.record(tool.schema.string(), tool.schema.any())
|
||||
.describe("The directive payload object"),
|
||||
},
|
||||
async execute(args) {
|
||||
return asText(
|
||||
await callBackend("POST", "/directives", {
|
||||
kind: args.kind,
|
||||
payload: args.payload || {},
|
||||
}),
|
||||
);
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
@@ -1,124 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Entrypoint for the roboco-agent-grok image (one-shot delivery roles).
|
||||
#
|
||||
# Renders opencode.json from the RoboCo spawn env (OPENAI_* + ROBOCO_*, set by
|
||||
# GrokProvider) plus the mounted Claude Code mcp-config.json, starts the
|
||||
# in-container SDK server (parity with the Claude SessionStart sdk-startup-hook),
|
||||
# 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
|
||||
|
||||
SDK_PORT="${ROBOCO_SDK_PORT:-9000}"
|
||||
SDK_URL="http://localhost:${SDK_PORT}"
|
||||
|
||||
# Generate opencode.json (provider + model + MCP gateway + permissions +
|
||||
# instructions). Writes to opencode's global config dir by default.
|
||||
# Run from /app so `python -m` resolves the INSTALLED roboco package. Dev/doc/qa
|
||||
# agents run at their workspace-clone cwd, which has its own `roboco/` dir on the
|
||||
# sys.path front (python -m prepends cwd); on a branch without the grok code that
|
||||
# clone lacks roboco.llm.providers and shadows /app → ModuleNotFoundError. The
|
||||
# config render has no cwd dependency (writes global, reads ROBOCO_MCP_CONFIG).
|
||||
( cd /app && python -m roboco.llm.providers.opencode_config )
|
||||
|
||||
# --- SDK server bring-up (Claude-parity) ----------------------------------
|
||||
# The flow/do MCP servers POST /verb/attempted here for the per-verb circuit
|
||||
# breaker; the budget-feed opencode plugin POSTs /budget/* + /terminal/* here;
|
||||
# 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
|
||||
# drifted lock and stall (the #179 fix the Claude hook needs `--no-sync` for).
|
||||
if ! curl -sf -m 2 "${SDK_URL}/health" >/dev/null 2>&1; then
|
||||
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
|
||||
if curl -sf -m 2 "${SDK_URL}/health" >/dev/null 2>&1; then break; fi
|
||||
sleep 0.5
|
||||
done
|
||||
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
|
||||
|
||||
# This is a one-shot delivery agent: the SDK budget server above is mandatory.
|
||||
# Tell the budget-feed plugin to FAIL CLOSED if that server ever goes
|
||||
# unreachable mid-run, so an unenforceable cost cap halts the burn instead of
|
||||
# letting it run uncapped. (Interactive serve images set no such flag.)
|
||||
export ROBOCO_BUDGET_ENFORCE=1
|
||||
|
||||
# Prompt-injection guard (parity with the Claude UserPromptSubmit hook): the
|
||||
# task prompt is DATA, not instructions — refuse a poisoned one before it
|
||||
# reaches the model. Same patterns as docker/scripts/user-prompt-hook.sh.
|
||||
if ! python -m roboco.agent_sdk.prompt_guard "${ROBOCO_INITIAL_PROMPT:-}"; then
|
||||
echo "Refusing to run: task prompt matched a prompt-injection pattern." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 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; tee captures the output for rate-limit detection and PIPESTATUS
|
||||
# preserves opencode's real exit code through the pipe.
|
||||
RUN_LOG="/tmp/opencode-run.log"
|
||||
set +e
|
||||
opencode run \
|
||||
--model "xai/${ROBOCO_AGENT_MODEL:-grok-build-0.1}" \
|
||||
"${variant_arg[@]}" \
|
||||
-- "${ROBOCO_INITIAL_PROMPT:-}" < /dev/null 2>&1 | tee "$RUN_LOG"
|
||||
run_rc=${PIPESTATUS[0]}
|
||||
set -e
|
||||
|
||||
# --- Rate-limit detection (B4) ---------------------------------------------
|
||||
# A 429 from xAI ends the one-shot run without the agent ever calling a terminal
|
||||
# verb. Detect it from the run output and exit 75 (EX_TEMPFAIL) so the
|
||||
# orchestrator PARKS the grok provider instead of the dispatcher re-spawning the
|
||||
# same task every tick (429 -> exit -> respawn -> 429, a cost/token loop). A
|
||||
# rate-limited task is NOT substituted — it must be retried once the limit lifts.
|
||||
RATE_LIMITED=0
|
||||
if grep -qiE '(\b429\b|too many requests|rate.?limit|quota exceeded|rate_limit_exceeded)' \
|
||||
"$RUN_LOG" 2>/dev/null; then
|
||||
RATE_LIMITED=1
|
||||
fi
|
||||
|
||||
# --- Post-mortem (Claude SessionEnd parity) — always -----------------------
|
||||
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 [ "$RATE_LIMITED" = "1" ]; then
|
||||
echo "[grok] xAI rate-limited — exiting 75 so the orchestrator parks the" \
|
||||
"provider; the task is retried when the limit lifts (not substituted)." >&2
|
||||
exit 75
|
||||
fi
|
||||
|
||||
# --- Silent-exit substitute (Claude Stop parity) ---------------------------
|
||||
# Only when NOT rate-limited: 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.
|
||||
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"
|
||||
@@ -36,16 +36,14 @@ fi
|
||||
RUN_LOG="/tmp/grok-run.json"
|
||||
ERR_LOG="/tmp/grok-run.err"
|
||||
WORKSPACE="${ROBOCO_WORKSPACE:-$PWD}"
|
||||
# A fixed session id (set by the provider) makes the run's session store
|
||||
# locatable for usage capture below; absent, grok generates its own.
|
||||
SESSION_ARGS=()
|
||||
[ -n "${ROBOCO_AGENT_SESSION_ID:-}" ] && SESSION_ARGS=(-s "${ROBOCO_AGENT_SESSION_ID}")
|
||||
# NOTE: grok generates its own session id and ignores a requested one (`-s` does
|
||||
# not pin it), so we do NOT pass a session id in; usage capture below reads the
|
||||
# real id back out of the JSON run log instead.
|
||||
set +e
|
||||
grok -p "${ROBOCO_INITIAL_PROMPT:-}" \
|
||||
-m "${ROBOCO_AGENT_MODEL:-grok-build}" \
|
||||
--cwd "$WORKSPACE" \
|
||||
--output-format json \
|
||||
"${SESSION_ARGS[@]}" \
|
||||
"${GROK_ARGS[@]}" \
|
||||
< /dev/null > "$RUN_LOG" 2> "$ERR_LOG"
|
||||
run_rc=$?
|
||||
@@ -54,18 +52,19 @@ set -e
|
||||
cat "$RUN_LOG"
|
||||
[ -s "$ERR_LOG" ] && cat "$ERR_LOG" >&2
|
||||
|
||||
# Capture token usage from the grok session store (~/.grok/sessions). The
|
||||
# orchestrator reads the written usage file back at finalize — the grok analogue
|
||||
# of the Claude transcript. Best-effort; never fails the run. Run from /app for
|
||||
# the same module-resolution reason as the render above.
|
||||
( cd /app && ROBOCO_GROK_RUN_CWD="$WORKSPACE" \
|
||||
# Capture token usage from the grok session store (~/.grok/sessions). The reader
|
||||
# reads the run's real session id out of $ROBOCO_GROK_RUN_LOG, locates the store,
|
||||
# and writes a usage.json the orchestrator reads back at finalize — the grok
|
||||
# analogue of the Claude transcript. Best-effort; never fails the run. Run from
|
||||
# /app for the same module-resolution reason as the render above.
|
||||
( cd /app && ROBOCO_GROK_RUN_CWD="$WORKSPACE" ROBOCO_GROK_RUN_LOG="$RUN_LOG" \
|
||||
python -m roboco.llm.providers.grok_cli_usage ) || true
|
||||
|
||||
# Rate-limit detection (parity with the opencode B4 path): an xAI 429 / quota
|
||||
# error ends the run without a terminal verb. Detect it from the run output and
|
||||
# exit 75 (EX_TEMPFAIL) so the orchestrator PARKS the grok provider instead of
|
||||
# the dispatcher respawning the same task every tick (429 -> exit -> respawn, a
|
||||
# token loop). A rate-limited task is retried once the limit lifts, not dropped.
|
||||
# Rate-limit detection: an xAI 429 / quota error ends the run without a terminal
|
||||
# verb. Detect it from the run output and exit 75 (EX_TEMPFAIL) so the
|
||||
# orchestrator PARKS the grok provider instead of the dispatcher respawning the
|
||||
# same task every tick (429 -> exit -> respawn, a token loop). A rate-limited task
|
||||
# is retried once the limit lifts, not dropped.
|
||||
if grep -qiE '(\b429\b|rate.?limit|too many requests|quota|insufficient_quota)' \
|
||||
"$RUN_LOG" "$ERR_LOG" 2>/dev/null; then
|
||||
echo "[grok] rate-limited — exiting 75 so the orchestrator parks the provider;" \
|
||||
|
||||
Reference in New Issue
Block a user