mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* feat(providers): Codex CLI provider — OpenAI via ModelProvider.OPENAI Mirrors the grok blueprint end to end: CodexCliProvider (RO ~/.codex mount, ANTHROPIC_* blanked), an orchestrator-side codex_auth.py refresher (JWT-exp staleness, atomic rewrite, lock-serialized single-use rotation, --check backstop; the CLI's own in-process refresh write no-ops on the RO mount by design — margins keep the orchestrator ahead of the CLI's 5-minute window), config.toml rendering with required=true gateway MCP servers, execpolicy deny rules (forbidden-only), per-role --sandbox (developer=workspace-write, review/doc roles read-only), codex exec --json with pinned ROBOCO_CODEX_CLI_MODEL (gpt-5.3-codex), usage summed from typed turn.completed events priced via the real 4-bucket split, dedicated image + entrypoint, registry/park/finalize/ compose/release wiring. V1 excludes interactive intake/secretary. Per adversarial review: migration 083 seeds the openai provider row enabled=True (without it every routing path 404'd — the whole feature was operationally dead code; grok needed the same seed in 039), the panel picker gained the OpenAI catalog group it silently lacked, and exit classification is structural — only stderr and error.message fields from error events are sniffed (word-boundaried patterns, exact auth phrases, bare 'login' dropped), so the model echoing on-topic words can never false-park the provider fleet-wide, proven by a benign-transcript test. Known open risk flagged, not claimed: whether codex's workspace-write OS sandbox excludes /app is unverified, and no hook mechanism exists to port the bash-guard defense-in-depth. * fix(providers): containment barrier on usage.json reads (code scanning) CodeQL flagged the codex usage read as path injection — correctly: os.path.basename does not neutralize '..', and the upstream segment validator isn't in CodeQL's taint model. The grok/codex reads collapse into one _read_usage_json_contained helper that resolves the built path and refuses anything outside the resolved usage root — a hostile id can never escape regardless of upstream drift. Traversal + containment regression tests added; a stray noqa in the test file replaced with a named constant per repo rule. * fix(providers): use realpath+startswith containment CodeQL recognizes The is_relative_to() guard was a real barrier but not in CodeQL's py/path-injection sanitizer model, so the alert persisted. Switch to the canonical os.path.realpath + startswith(root + os.sep) form, which CodeQL recognizes as a path-traversal barrier; behavior is identical (refuse any candidate resolving outside the usage root). * fix(providers): regexp-allowlist the usage-id segment (CodeQL barrier) Neither is_relative_to nor realpath+startswith was recognized by CodeQL's py/path-injection sanitizer model across the str->Path->open flow. Sanitize the tainted component at the source instead: the id must fullmatch a strict slug token ([A-Za-z0-9][A-Za-z0-9._-]*, no separators, no '..'), which CodeQL recognizes as a path-injection barrier; the realpath+startswith containment stays as defense-in-depth. * fix(providers): standalone regexp guard so CodeQL recognizes the barrier The sanitizer was one disjunct of a compound 'or' condition, which CodeQL's guard analysis does not trace as a barrier. Split the regexp fullmatch into its own single-condition guard (the redundant '..' check is dropped — the required alphanumeric first char already excludes it). --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
121 lines
4.8 KiB
Python
121 lines
4.8 KiB
Python
"""codex_cli_config — mcp-config → config.toml + execpolicy rules + combined
|
|
prompt + per-role sandbox flag."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import tomllib
|
|
from typing import TYPE_CHECKING
|
|
|
|
from roboco.llm.providers import codex_cli_config as cc
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
_SAMPLE_MCP = {
|
|
"mcpServers": {
|
|
"roboco-flow": {
|
|
"command": "uv",
|
|
"args": ["run", "--no-sync", "python", "-m", "roboco.mcp.flow_server"],
|
|
"env": {"ROBOCO_AGENT_ID": "be-dev-1", "ROBOCO_AGENT_TOKEN": "tok-123"},
|
|
},
|
|
"roboco-do": {"command": "uv", "args": ["run", "x"]},
|
|
"roboco-optimal": {"command": "uv", "args": ["run", "y"]},
|
|
}
|
|
}
|
|
|
|
|
|
def test_render_config_toml_is_valid_toml_and_injects_env() -> None:
|
|
parsed = tomllib.loads(cc.render_config_toml(_SAMPLE_MCP))
|
|
flow = parsed["mcp_servers"]["roboco-flow"]
|
|
assert flow["command"] == "uv"
|
|
assert flow["args"][:2] == ["run", "--no-sync"]
|
|
assert flow["env"]["ROBOCO_AGENT_TOKEN"] == "tok-123"
|
|
assert "env" not in parsed["mcp_servers"]["roboco-do"]
|
|
|
|
|
|
def test_render_config_toml_marks_gateway_pair_required() -> None:
|
|
parsed = tomllib.loads(cc.render_config_toml(_SAMPLE_MCP))
|
|
assert parsed["mcp_servers"]["roboco-flow"]["required"] is True
|
|
assert parsed["mcp_servers"]["roboco-do"]["required"] is True
|
|
# Every other server is best-effort — no `required` key at all.
|
|
assert "required" not in parsed["mcp_servers"]["roboco-optimal"]
|
|
|
|
|
|
def test_render_config_toml_empty_when_no_servers() -> None:
|
|
assert cc.render_config_toml({}) == ""
|
|
assert cc.render_config_toml({"mcpServers": {}}) == ""
|
|
|
|
|
|
def test_sandbox_level_developer_is_workspace_write() -> None:
|
|
assert cc.sandbox_level_for_role("developer") == "workspace-write"
|
|
|
|
|
|
def test_sandbox_level_other_delivery_roles_are_read_only() -> None:
|
|
# Narrower than grok's per-role allows_write (documenter also writes there)
|
|
# — Codex V1 restricts local sandbox writes to developer only; documenter's
|
|
# real writes ride the roboco-docs MCP server, not a local file edit.
|
|
for role in ("qa", "documenter", "pr_reviewer", "cell_pm", "main_pm", ""):
|
|
assert cc.sandbox_level_for_role(role) == "read-only"
|
|
|
|
|
|
def test_codex_cli_args_for_role_carries_sandbox_and_skip_git_check() -> None:
|
|
dev_args = cc.codex_cli_args_for_role("developer")
|
|
assert dev_args == ["--sandbox", "workspace-write", "--skip-git-repo-check"]
|
|
qa_args = cc.codex_cli_args_for_role("qa")
|
|
assert qa_args == ["--sandbox", "read-only", "--skip-git-repo-check"]
|
|
|
|
|
|
def test_render_execpolicy_rules_covers_git_mutation_destructive_and_raw_pm() -> None:
|
|
rules = cc.render_execpolicy_rules()
|
|
assert 'prefix_rule(pattern = ["git", "push"], decision = "forbidden")' in rules
|
|
assert 'prefix_rule(pattern = ["git", "tag", "-d"], decision = "forbidden")' in (
|
|
rules
|
|
)
|
|
assert 'prefix_rule(pattern = ["rm", "-rf"], decision = "forbidden")' in rules
|
|
assert 'prefix_rule(pattern = ["uv", "run"], decision = "forbidden")' in rules
|
|
assert 'prefix_rule(pattern = ["pip", "install"], decision = "forbidden")' in rules
|
|
# Only allow/forbidden decisions — never `prompt` (blocks headless turns).
|
|
assert "prompt" not in rules
|
|
|
|
|
|
def test_write_execpolicy_rules_writes_to_dest(tmp_path: Path) -> None:
|
|
dest = tmp_path / "rules" / "default.rules"
|
|
cc.write_execpolicy_rules(dest=dest)
|
|
assert dest.exists()
|
|
assert "git" in dest.read_text(encoding="utf-8")
|
|
|
|
|
|
def test_render_combined_prompt_joins_system_and_task() -> None:
|
|
combined = cc.render_combined_prompt("You are the developer.", "Fix the bug.")
|
|
assert combined.startswith("You are the developer.")
|
|
assert combined.endswith("Fix the bug.")
|
|
assert "---" in combined
|
|
|
|
|
|
def test_render_combined_prompt_degrades_gracefully() -> None:
|
|
assert cc.render_combined_prompt("", "task only") == "task only"
|
|
assert cc.render_combined_prompt("system only", "") == "system only"
|
|
assert cc.render_combined_prompt("", "") == ""
|
|
|
|
|
|
def test_write_combined_prompt_reads_source_and_writes_dest(tmp_path: Path) -> None:
|
|
src = tmp_path / "system-prompt.md"
|
|
src.write_text("You are the RoboCo developer.", encoding="utf-8")
|
|
dest = tmp_path / "prompt.txt"
|
|
found = cc.write_combined_prompt(
|
|
task_prompt="Implement the feature.", source=src, dest=dest
|
|
)
|
|
assert found is True
|
|
text = dest.read_text(encoding="utf-8")
|
|
assert "You are the RoboCo developer." in text
|
|
assert "Implement the feature." in text
|
|
|
|
|
|
def test_write_combined_prompt_degrades_when_source_absent(tmp_path: Path) -> None:
|
|
dest = tmp_path / "prompt.txt"
|
|
found = cc.write_combined_prompt(
|
|
task_prompt="Implement the feature.", source=tmp_path / "absent.md", dest=dest
|
|
)
|
|
assert found is False
|
|
assert dest.read_text(encoding="utf-8") == "Implement the feature."
|