diff --git a/docker-compose.registry.yml b/docker-compose.registry.yml index bd32275ad..7875c9e42 100644 --- a/docker-compose.registry.yml +++ b/docker-compose.registry.yml @@ -775,6 +775,16 @@ services: NGINX_ENVSUBST_FILTER: "^ROBOCO_" volumes: - ./deploy/nginx.conf:/etc/nginx/templates/default.conf.template:ro + # The active-color include is mounted via its DIRECTORY, not as a file + # bind: a file bind pins the inode, so a host-side rewrite (or the + # deploy script's atomic tmp+mv swap) never reaches the running + # container and nginx keeps testing/serving the PREVIOUS color + # (2026-09-17 green flip: "host not found in upstream + # roboco-panel-blue" after the include already said green). + # Registry installs clone the repo, so ./front ships with it; missing + # this mount made fresh `make quickstart` crash-loop nginx on the + # include (#1111). + - ./front:/etc/nginx/front:ro depends_on: - panel - orchestrator diff --git a/roboco/llm/providers/openrouter_cli_config.py b/roboco/llm/providers/openrouter_cli_config.py index 51647440e..7f1bad6c0 100644 --- a/roboco/llm/providers/openrouter_cli_config.py +++ b/roboco/llm/providers/openrouter_cli_config.py @@ -468,6 +468,10 @@ def main(argv: list[str] | None = None) -> int: base_url = os.environ.get("OPENROUTER_BASE_URL", settings.openrouter_base_url) config = render_config(role, model, base_url, mcp_path) + # The image does not pre-create opencode's global config home (#1110): + # without this, every OpenRouter-routed spawn died on FileNotFoundError + # before the CLI ever started. + OPENCODE_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) OPENCODE_CONFIG_PATH.write_text(json.dumps(config, indent=2), encoding="utf-8") write_bash_guard_plugin() return 0 diff --git a/tests/unit/llm/providers/test_openrouter_cli_config.py b/tests/unit/llm/providers/test_openrouter_cli_config.py index 030f176e1..67d07e596 100644 --- a/tests/unit/llm/providers/test_openrouter_cli_config.py +++ b/tests/unit/llm/providers/test_openrouter_cli_config.py @@ -347,6 +347,33 @@ def test_main_writes_opencode_json( assert "tool.execute.before" in plugin_path.read_text(encoding="utf-8") +def test_main_creates_missing_config_dir( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """#1110: the agent image does not pre-create opencode's global config + home, so the render step must mkdir the parent itself. Regression: every + OpenRouter-routed spawn exited 1 on FileNotFoundError before the CLI + started. The plugin write already mkdir'd its parents; the config write + did not.""" + mcp_path = tmp_path / "mcp-config.json" + mcp_path.write_text(json.dumps(_SAMPLE_MCP), encoding="utf-8") + config_path = tmp_path / ".config" / "opencode" / "opencode.json" + plugin_path = tmp_path / ".config" / "opencode" / "plugins" / "guard.js" + system_prompt = tmp_path / "system-prompt.md" + system_prompt.write_text("blueprint", encoding="utf-8") + + monkeypatch.setattr(oc, "OPENCODE_CONFIG_PATH", config_path) + monkeypatch.setattr(oc, "OPENCODE_PLUGIN_PATH", plugin_path) + monkeypatch.setattr(oc, "SYSTEM_PROMPT_PATH", system_prompt) + monkeypatch.setenv("ROBOCO_AGENT_ID", "be-dev-1") + monkeypatch.setenv("ROBOCO_MCP_CONFIG", str(mcp_path)) + monkeypatch.setenv("ROBOCO_AGENT_MODEL", "anthropic/claude-sonnet-4") + + assert oc.main([]) == 0 + assert config_path.exists() + json.loads(config_path.read_text(encoding="utf-8")) + + def test_main_check_flag_passes_when_key_set( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/unit/test_compose_roles.py b/tests/unit/test_compose_roles.py index cd6dc2532..2b6905c0d 100644 --- a/tests/unit/test_compose_roles.py +++ b/tests/unit/test_compose_roles.py @@ -98,3 +98,33 @@ def test_indexer_services_have_no_http_healthcheck() -> None: f"{name}: an indexer service serves no HTTP, a healthcheck " "can never pass" ) + + +def test_nginx_mounts_front_include_dir_in_every_compose() -> None: + """#1111: deploy/nginx.conf includes /etc/nginx/front/active-upstreams.conf + (the blue-green color switch), so EVERY compose file must mount the repo's + ./front directory at /etc/nginx/front. 2026-09-21 the registry compose + shipped without it and fresh `make quickstart` crash-looped nginx on the + missing include. The mount must be the DIRECTORY, not a file bind: a file + bind pins the inode and the deploy script's atomic tmp+mv swap would never + reach the running container.""" + for name in _COMPOSE_FILES: + compose = yaml.safe_load((_REPO_ROOT / name).read_text()) + nginx = compose["services"].get("nginx") + assert nginx is not None, f"{name}: no nginx service found" + volumes = [str(v) for v in (nginx.get("volumes") or [])] + dir_mount = any( + v.startswith("./front:") and v.endswith(":/etc/nginx/front:ro") + for v in volumes + ) + assert dir_mount, ( + f"{name}: nginx must mount ./front:/etc/nginx/front:ro (directory " + "bind, not a file bind) or the active-upstreams include in " + "deploy/nginx.conf crash-loops the container on a fresh install" + ) + # And the included file must actually exist in the repo, so the mount + # is never empty on a clean clone. + assert (_REPO_ROOT / "front" / "active-upstreams.conf").is_file(), ( + "front/active-upstreams.conf is missing from the repo; the nginx " + "include would 404 it and crash-loop roboco-nginx" + )