Skip to content

chore(#3d): delete the OpenCode substrate — Pydantic AI is the only agent runtime#254

Merged
galanko merged 14 commits into
feat/pa-migrate-normalizerfrom
feat/pa-drop-opencode-substrate
Jun 3, 2026
Merged

chore(#3d): delete the OpenCode substrate — Pydantic AI is the only agent runtime#254
galanko merged 14 commits into
feat/pa-migrate-normalizerfrom
feat/pa-drop-opencode-substrate

Conversation

@galanko

@galanko galanko commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator

User description

Stacked on #253 (feat/pa-migrate-normalizer). Final PR of the OpenCode → Pydantic AI migration (ADR-0047 / IMPL-0022): every OpenCode consumer is gone, so this removes the engine itself and everything that fed it.

What this does (7 commits, each independently green)

  1. refactor(settings)/settings/model + /settings/providers + /providers/test now derive from canonical AI state (cliff.ai.catalog + AIIntegrationService + a PA "Say OK" probe) instead of config_manager/opencode_client. Deleted the vestigial /providers/configured + /api-keys/* routes (no live UI consumer) and their dead frontend wrappers/hooks/types. CLI cliffsec model get/set/list wire shapes unchanged.
  2. refactor(ai) — dropped the auth.json dual-path (sync_to_opencode, _sync/_clear_opencode_auth, _safe_write_opencode_config).
  3. refactor(main) — removed the singleton OpenCode lifespan, the spawned-but-unused WorkspaceProcessPool (the executor never called it after #3c), the idle-cleanup loop, and the singleton-restart hook. AgentExecutor drops its unused pool arg.
  4. refactor(workspace) — workspace dirs stop scaffolding opencode.json + .opencode/agents/*.md; create_repo_workspace is now a thin clone-dir allocator. The dir still carries the finding context the PA agents read.
  5. refactor(version)/version + /health report the in-process substrate version (pydantic-ai <ver>); VersionInfo moved to cliff.models.
  6. chore: delete the OpenCode substrate — deletes backend/cliff/engine/, template_engine.py + templates/*.j2, config.py opencode settings, the engine/pool/template tests + OpenCode e2e suite, .opencode-version/opencode.json/.opencode//scripts/install-opencode.sh, the Dockerfile binary step, and the install-tooling + CLI bits that bundled/installed/doctored the binary.
  7. docs — CLAUDE.md updated to the PA substrate.

Verification

  • Backend pytest -m 'not e2e': 1432 passed, 14 skipped (ruff clean)
  • CLI: 97 passed (ruff clean)
  • Frontend: tsc --noEmit clean
  • OpenAPI snapshot regenerated

Deliberately deferred (noted for follow-up, not blocking)

  • CLI dead code: process_sweep.py still classifies a $CLIFF_HOME/bin/opencode process kind and cli.py keeps the (now always "ok") opencode health field — dead but harmless (they only match a binary that no longer exists). A focused CLI-cleanup PR.
  • frontend/src/api/types.ts resync (pre-existing ~2000-line drift) — its own PR.
  • cliff-os docs (IMPL-0022 status, workflow diagram) — architect pass.

Not for merge yet — this is the next layer of the stack over #253.

🤖 Generated with Claude Code


Generated description

Below is a concise technical summary of the changes proposed in this PR:
Replace the legacy OpenCode engine + template-driven agent renderer with an in-process Pydantic AI substrate (ADR-0047), rewire all workspace/context layers, provider settings, tooling, fixtures, docs, and install artifacts to the new architecture, and remove the obsolete engine, templates, OpenCode scaffolding, and CI/CLI references that depended on it.

TopicDetails
AI state, settings & provider probe Canonical AI state, settings/probe endpoints, provider catalog, and frontend hooks now serve Pydantic AI instead of OpenCode, with new resolver-driven env/model plumbing, provider probe logic, health/version reporting, and cleaned-up API surfaces.
Modified files (15)
  • backend/cliff/ai/catalog.py
  • backend/cliff/ai/models.py
  • backend/cliff/ai/service.py
  • backend/cliff/api/_engine_dep.py
  • backend/cliff/api/routes/ai_integrations.py
  • backend/cliff/api/routes/health.py
  • backend/cliff/api/routes/settings.py
  • backend/cliff/api/routes/version.py
  • backend/tests/api/openapi_snapshot.json
  • backend/tests/test_routes_ai_integrations.py
  • backend/tests/test_routes_ai_openrouter.py
  • backend/tests/test_routes_settings.py
  • frontend/src/api/client.ts
  • frontend/src/api/hooks.ts
  • frontend/src/test/msw/sessionHandlers.ts
Latest Contributors(1)
UserCommitDate
galank@gmail.comdocs: scrub stale Open...June 02, 2026
Other Other files
Modified files (17)
  • backend/cliff/agents/errors.py
  • backend/cliff/agents/template_engine.py
  • backend/cliff/agents/templates/dependabot_config_generator.md.j2
  • backend/cliff/agents/templates/enricher.md.j2
  • backend/cliff/agents/templates/evidence_collector.md.j2
  • backend/cliff/agents/templates/exposure_analyzer.md.j2
  • backend/cliff/agents/templates/orchestrator.md.j2
  • backend/cliff/agents/templates/owner_resolver.md.j2
  • backend/cliff/agents/templates/remediation_executor.md.j2
  • backend/cliff/agents/templates/remediation_planner.md.j2
  • backend/cliff/agents/templates/security_md_generator.md.j2
  • backend/cliff/agents/templates/validation_checker.md.j2
  • backend/cliff/config.py
  • backend/cliff/main.py
  • backend/cliff/models/__init__.py
  • backend/cliff/workspace/repo_workspace_runner.py
  • backend/tests/e2e/__init__.py
Latest Contributors(1)
UserCommitDate
galank@gmail.comdocs: scrub stale Open...June 02, 2026
Architecture migration to Pydantic AI Core runtime shift from external OpenCode subprocesses to an in‑process Pydantic AI substrate, including executor orchestration, workspace context management, runtime tool plumbing, repo-action scaffolding, and agent runtime definitions.
Modified files (22)
  • backend/cliff/agents/__init__.py
  • backend/cliff/agents/executor.py
  • backend/cliff/agents/runtime/deps.py
  • backend/cliff/agents/runtime/no_tools.py
  • backend/cliff/agents/runtime/provider.py
  • backend/cliff/agents/runtime/remediation_executor.py
  • backend/cliff/agents/runtime/tools/__init__.py
  • backend/cliff/agents/runtime/tools/bash.py
  • backend/cliff/agents/runtime/tools/mcp.py
  • backend/cliff/agents/runtime/tools/permissions.py
  • backend/cliff/workspace/context_builder.py
  • backend/cliff/workspace/workspace_dir.py
  • backend/cliff/workspace/workspace_dir_manager.py
  • backend/tests/agents/conftest.py
  • backend/tests/agents/fixtures/plain_description_evals.json
  • backend/tests/agents/test_deferred_tools_persist.py
  • backend/tests/agents/test_executor_provider_chain.py
  • backend/tests/integration/test_permission_flow.py
  • backend/tests/integration/test_rate_limit_backoff.py
  • backend/tests/test_context_builder.py
  • backend/tests/test_executor.py
  • backend/tests/test_workspace_dir.py
Latest Contributors(1)
UserCommitDate
galank@gmail.comdocs: scrub stale Open...June 02, 2026
Engine/tooling/docs cleanup System/ops cleanup: remove OpenCode-specific artifacts (engine package, config scaffolding, templates, install scripts, Dockerfile references, CLI/daemon checks, CI workflow triggers, docs, and repo-action template/tests) now obsolete after the substrate change.
Modified files (49)
  • .github/workflows/install-smoke.yml
  • .opencode-version
  • .opencode/agents/security-orchestrator.md
  • CLAUDE.md
  • backend/cliff/engine/__init__.py
  • backend/cliff/engine/client.py
  • backend/cliff/engine/config_manager.py
  • backend/cliff/engine/models.py
  • backend/cliff/engine/pool.py
  • backend/cliff/engine/process.py
  • backend/cliff/integrations/gateway.py
  • backend/tests/conftest.py
  • backend/tests/e2e/conftest.py
  • backend/tests/e2e/test_agent_pipeline_e2e.py
  • backend/tests/e2e/test_health_e2e.py
  • backend/tests/e2e/test_process_pool_e2e.py
  • backend/tests/e2e/test_repo_workspace_agents.py
  • backend/tests/e2e/test_settings_e2e.py
  • backend/tests/integration/test_workspace_npm_cache_isolation.py
  • backend/tests/integration/test_workspace_repo_url_overrides_global.py
  • backend/tests/test_agent_template_engine.py
  • backend/tests/test_ai_phase_f_wiring.py
  • backend/tests/test_ai_service.py
  • backend/tests/test_config.py
  • backend/tests/test_config_manager.py
  • backend/tests/test_docker.py
  • backend/tests/test_engine_client.py
  • backend/tests/test_engine_process.py
  • backend/tests/test_gateway.py
  • backend/tests/test_mcp_gateway_integration.py
  • backend/tests/test_permission_client.py
  • backend/tests/test_process_pool.py
  • backend/tests/test_repo_action_templates.py
  • backend/tests/test_routes_version.py
  • backend/tests/test_workspace_dir.py
  • backend/tests/test_workspace_integrations.py
  • backend/tests/test_workspace_repo_creation.py
  • cli/cliff_cli/daemon.py
  • cli/cliff_cli/updater.py
  • cli/tests/test_daemon.py
  • cli/tests/test_updater.py
  • docker/Dockerfile
  • frontend/src/api/client.ts
  • frontend/src/api/hooks.ts
  • opencode.json
  • scripts/build-tarball.sh
  • scripts/install-local.sh
  • scripts/install-opencode.sh
  • tests/install/smoke.sh
Latest Contributors(1)
UserCommitDate
galank@gmail.comchore: delete the Open...June 02, 2026
Review this PR on Baz | Customize your next review

galanko and others added 7 commits June 2, 2026 18:32
Drop config_manager / opencode_client from the settings routes — the last
contract-bearing OpenCode dependency outside the engine package:

- GET/PUT /settings/model now route only through AIIntegrationService
  (canonical app_setting(model)); no opencode.json fallback. Empty
  ModelConfig when no provider is connected; PUT 503s without a vault and
  400s with no active provider / prefix mismatch.
- GET /settings/providers is rebuilt from cliff.ai.catalog (static
  per-provider picker rows) instead of OpenCode's models.dev dump. Wire
  shape {id,name,env,models} is preserved; models keyed by the bare id so
  the UI/CLI rebuild f"{provider}/{model_id}" unchanged.
- POST /settings/providers/test now runs a bounded "Say OK" through
  Pydantic AI (build_model + Agent) and classifies the outcome.
- Delete the vestigial config_manager-only routes /settings/providers/
  configured and /settings/api-keys/* (no live UI consumer) plus their
  dead frontend wrappers/hooks/types and the MSW api-keys handler.

CLI (cliffsec model get/set/list) is unaffected — same wire shapes.

Part of PR #3d (OpenCode substrate removal), stacked on #253.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ADR-0047: the PA model factory reads the provider key straight from the
resolved env (resolve_env_for_workspace) — there is no auth.json to keep
in sync. Remove sync_to_opencode, _sync_opencode_auth, _clear_opencode_auth,
_safe_write_opencode_config, the _OPENCODE_AUTH_PROVIDERS set, and all their
call sites in save/disconnect/set_model. Canonical vault + app_setting(model)
state is untouched; _fire_key_change stays as a generic env-refresh hook.

Drop the now-obsolete auth.json-push tests and the dead opencode_client
stub fixtures.

Part of PR #3d, stacked on #253.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ADR-0047: agents run in-process via Pydantic AI, so nothing spawns or
talks to an OpenCode process anymore. Remove from the lifespan:

- the singleton opencode_process start/stop + set_extra_env seeding
- the auth.json sync_to_opencode reconcile + config_manager
  reconcile_model / restore_keys_to_engine startup calls
- the WorkspaceProcessPool (constructed but never read after #3c — the
  executor never called it), its app.state handle, the idle-cleanup loop,
  and pool.stop_all on shutdown
- the singleton-restart half of the ai_on_key_change hook (it now only
  refreshes the warm env/model cache)
- opencode_client.close() / opencode_process.stop() teardown

AgentExecutor drops its unused ``pool`` parameter. Test construction
sites updated to the new signature.

Part of PR #3d, stacked on #253.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ates

ADR-0047: PA agents run in-process from prompts defined in
cliff.agents.runtime — they never read a workspace's opencode.json or
rendered .opencode/agents/*.md. Stop writing both:

- WorkspaceDirManager.create() drops the .opencode/agents tree, the
  opencode.json write, and its mcp_servers/model params. The dir still
  carries the finding context (finding.json, finding.md, CONTEXT.md,
  context sections) the agents read.
- create_repo_workspace() becomes a thin clone-dir allocator (history/ +
  REPO_ACTION.md); no rendered prompt, no opencode.json. The repo-action
  agent carries its own prompt + permission policy.
- WorkspaceContextBuilder drops the AgentTemplateEngine dependency and the
  per-create / per-update write_agents render passes; it still writes the
  workspace-integrations.json manifest (read by check_config_freshness).
- Delete _build_opencode_config and the WorkspaceDir opencode_json /
  opencode_dir / agents_dir properties.
- main.py lifespan + _engine_dep spawner drop the template engine / model
  plumbing.

Tests updated to assert finding context (not templates); the obsolete
opencode.json/template assertions and the OpenCode pool-wiring test
(test_ai_phase_f_wiring) are removed.

Part of PR #3d, stacked on #253.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move VersionInfo out of the doomed cliff.engine.models into cliff.models
alongside HealthStatus, and add a shared substrate_version() helper. The
/version handshake's compat ``opencode`` field and /health's
``opencode_version`` both now carry "pydantic-ai <ver>" (ADR-0047) instead
of the pinned OpenCode binary version. /health stops falling back to
settings.opencode_model (opencode.json is gone) — the model is the
canonical ai_model_cache or empty.

The config.py opencode_* settings removal rides with the engine deletion
(they're read by engine/ at runtime until it's deleted).

Part of PR #3d, stacked on #253.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every OpenCode caller is gone (#3b/#3c + steps 1-5), so remove the engine
itself and everything that fed it:

Backend
- delete backend/cliff/engine/ (client, process, pool, config_manager,
  models) and backend/cliff/agents/template_engine.py + templates/*.j2
- config.py: drop the opencode_* connection/model/version settings and
  properties; repo-root sentinel moves from .opencode-version to VERSION
- agents/__init__.py stops re-exporting the template engine
- delete the engine/pool/template unit tests + the OpenCode e2e suite;
  strip the opencode fixtures from the conftests; retarget test_config /
  test_docker / the openrouter route test; regenerate the OpenAPI snapshot

Packaging / CLI
- Dockerfile: drop the OpenCode binary install step, the opencode.json /
  .opencode COPYs, and the CLIFF_OPENCODE_* env
- build-tarball / install-local / install smoke / install-smoke.yml stop
  bundling + installing + asserting the opencode binary
- CLI updater stops re-running install-opencode.sh; daemon doctor drops the
  opencode binary check (it would report "not found" post-migration)

Root
- delete .opencode-version, opencode.json, .opencode/, scripts/install-opencode.sh

Follow-up (noted, not blocking): the CLI's process_sweep still classifies a
``$CLIFF_HOME/bin/opencode`` process kind and cli.py keeps the (now always
"ok") opencode health field — dead but harmless; a separate cleanup PR.

Backend 1432 pass, CLI 97 pass, frontend tsc clean, ruff clean.

Part of PR #3d, stacked on #253.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Architecture table, repo layout, "How It Runs", the workspace-runtime
layers, the AI-provider section, and the testing guidance now describe the
in-process Pydantic AI substrate (ADR-0047) instead of the OpenCode
subprocess / process pool / auth.json / drift model.

Part of PR #3d, stacked on #253.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 2, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a5084a89-3e15-49c7-bdd9-7f9dd9976059

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR eliminates the OpenCode subprocess engine and refactors Cliff to run agents in-process via Pydantic AI (ADR-0047). Configuration is simplified by removing OpenCode-specific settings and switching repo root detection to VERSION. Agent templates and workspace rendering are removed; workspace creation now uses finding-only context with optional integrations. The AI integration service relies solely on key-change hooks instead of auth.json synchronization. Settings endpoints are rewritten to probe via Pydantic AI instead of OpenCode. Startup logic is simplified to initialize only persistence, AI state, and background workers. OpenCode engine modules and E2E tests are deleted. Installers and Docker packaging are updated to remove OpenCode binary installation.

Changes

Refactor from OpenCode subprocess to in-process Pydantic AI agents (ADR-0047)

Layer / File(s) Summary
Configuration & repo root detection
backend/cliff/config.py, .opencode-version, opencode.json, .opencode/agents/security-orchestrator.md
Repo root detection switches from .opencode-version to VERSION file; Settings removes opencode_host, opencode_port, opencode_bin, port-range, and idle-timeout fields; removes opencode_url, opencode_binary_path, opencode_version, opencode_model properties and write_opencode_config() method.
Agent executor & orchestration
backend/cliff/agents/__init__.py, backend/cliff/agents/executor.py, backend/cliff/agents/template_engine.py, backend/cliff/agents/templates/*.md.j2
AgentExecutor no longer accepts pool: WorkspaceProcessPool; agents package drops AGENT_NAMES, AgentTemplateEngine, RenderedAgent exports; entire template engine module and all 10 Jinja2 agent prompt templates are deleted.
Workspace creation & context
backend/cliff/workspace/context_builder.py, backend/cliff/workspace/workspace_dir_manager.py, backend/cliff/workspace/workspace_dir.py
WorkspaceContextBuilder and WorkspaceDirManager.create() remove template_engine dependency and model parameter; workspace creation no longer writes opencode.json or .opencode/agents/ directory; only finding and integration context persisted.
AI integration & service
backend/cliff/ai/service.py
AIIntegrationService.sync_to_opencode() method removed; _safe_write_opencode_config() helper deleted; OpenCode auth.json push/pull logic removed; key changes now propagate solely via on_key_change hook + resolve_env_for_workspace.
Version & health endpoints
backend/cliff/models/__init__.py, backend/cliff/api/routes/health.py, backend/cliff/api/routes/version.py
New substrate_version() function and VersionInfo model report in-process Pydantic AI version; /health and /version endpoints use substrate_version() instead of settings.opencode_version.
Settings API refactor
backend/cliff/api/routes/settings.py, backend/cliff/api/_engine_dep.py
/settings/model GET resolves via AIIntegrationService or returns empty; PUT requires vault (503 if missing), routes through AIIntegrationService.set_model, handles ModelPrefixMismatchError and NoActiveProviderError; /settings/providers builds catalog from cliff.ai.catalog; /settings/providers/test probe rewritten to use _probe_pa() with pydantic_ai.Agent and maps PydanticAI exceptions to structured error codes; /settings/api-keys endpoints and /settings/providers/configured removed; create_repo_workspace() call no longer passes gh_token or model.
App startup/shutdown
backend/cliff/main.py
Lifespan refactored to initialize only persistence, AI state, and background workers; removes OpenCode process startup, process pool wiring, model reconciliation, and auth sync; replaces with MCP resolution, context builder, ai_on_key_change hook, and module-level AgentExecutor.
Remove OpenCode engine
backend/cliff/engine/pool.py, backend/cliff/engine/process.py, backend/cliff/engine/config_manager.py, backend/cliff/engine/models.py, backend/cliff/engine/__init__.py
Entire WorkspaceProcessPool, OpenCodeProcess, ConfigManager, and OpenCode API model classes deleted; module-level opencode_process singleton removed; no process-pool lifecycle management or port allocation.
Installer & CLI
cli/cliff_cli/daemon.py, cli/cliff_cli/updater.py, scripts/install-opencode.sh, scripts/build-tarball.sh, scripts/install-local.sh, cli/tests/test_updater.py, cli/tests/test_daemon.py
OpenCode binary version check removed from doctor; quarantine check loop on macOS no longer includes opencode; REQUIRED_TARBALL_MEMBERS drops scripts/install-opencode.sh; install-opencode.sh script deleted; _run_bundled_installers() now runs only install-scanners.sh; tarball build excludes .opencode-version; install-local.sh simplifies to scanners-only; test fixtures and expectations updated.
Docker & deployment
docker/Dockerfile
Copy only VERSION (not .opencode-version); remove OpenCode binary install step; stop copying opencode.json and .opencode/ directory; remove CLIFF_OPENCODE_HOST, CLIFF_OPENCODE_PORT, CLIFF_OPENCODE_BIN environment variables.
Frontend API cleanup
frontend/src/api/client.ts, frontend/src/api/hooks.ts, frontend/src/test/msw/sessionHandlers.ts
Remove ApiKeyInfo type; delete getConfiguredProviders, listApiKeys, setApiKey, deleteApiKey client methods and hooks; remove MSW handler for PUT /api/settings/api-keys/:provider.
Test infrastructure & fixtures
backend/tests/conftest.py, backend/tests/agents/conftest.py, backend/tests/e2e/conftest.py
Replace OpenCode server fixtures with LLM API key checks; simplify client fixture; remove E2E opencode_server, app_client, and port/binary detection fixtures.
Remove OpenCode-dependent tests
backend/tests/e2e/test_*.py, backend/tests/test_config_manager.py, backend/tests/test_engine_*.py, backend/tests/test_process_pool.py, backend/tests/test_repo_action_templates.py, backend/tests/test_agent_template_engine.py, backend/tests/test_ai_phase_f_wiring.py
Delete E2E modules (test_agent_pipeline_e2e.py, test_process_pool_e2e.py, test_repo_workspace_agents.py, test_settings_e2e.py, test_health_e2e.py); remove all OpenCode-specific unit test modules (config_manager, engine process/client, process pool, repo action templates, template engine, phase F wiring).
Update executor-dependent tests
backend/tests/test_executor.py, backend/tests/agents/test_*.py, backend/tests/integration/test_*.py, backend/tests/test_gateway.py
Remove mock_pool fixture and pool parameter from all AgentExecutor constructor calls; refactor _executor_with_pa helper to construct executor with only mock_context_builder plus AI resolvers; update 50+ test methods across executor, agent, integration, and gateway test suites.
Update MCP gateway & workspace tests
backend/tests/test_mcp_gateway_integration.py, backend/tests/test_workspace_dir.py, backend/tests/test_workspace_integrations.py, backend/tests/test_workspace_repo_creation.py, backend/tests/test_context_builder.py
Remove AgentTemplateEngine imports and constructor calls from all workspace/integration test fixtures; update test_create_full_structure to assert finding/history/context files instead of agents_dir and opencode_json; remove test_opencode_json_* and test_update_context_re_renders_agents tests; simplify builder fixtures to use only mcp_resolver.
Refactor settings route tests
backend/tests/test_routes_settings.py, backend/tests/test_routes_ai_*.py
Model endpoint tests mock AIIntegrationService instead of opencode_client; update provider catalog to assert real structure; rewrite probe tests to patch build_model with PydanticAI exceptions and map to error codes; remove fake HTTP session mocking; remove OpenCode auth-stub fixtures from integration and OpenRouter route tests.
Update AI service & config tests
backend/tests/test_ai_service.py, backend/tests/test_config.py, backend/tests/test_docker.py
Remove "OpenCode auth.json sync" test section; replace OpenCode auth-stub with catalog-reset fixture; update docstrings for set_model and get_status tests to reflect ADR-0047 key-change hook behavior; narrow test_default_settings to app_host/app_port only; update repo root detection to VERSION file; remove OpenCode property tests.
Update version & gateway tests
backend/tests/test_routes_version.py, backend/tests/test_gateway.py
Replace test_version_opencode_matches_pinned_engine with test_version_opencode_reports_substrate (assert "pydantic-ai" prefix); remove json import and workspace opencode.json MCP test cases from gateway tests.
OpenAPI snapshot updates
backend/tests/api/openapi_snapshot.json
Remove ApiKeyCreate schema and /api/settings/api-keys endpoints; update ProviderTestRequest description (canonical probe behavior); update VersionInfo (empty opencode default, make non-required, describe in-process substrate); update endpoint descriptions for /api/settings/model, /api/settings/providers, /api/settings/providers/test.
Documentation updates
CLAUDE.md, .github/workflows/install-smoke.yml, tests/install/smoke.sh
Update architecture to describe in-process Pydantic AI agents; remove OpenCode engine/process-pool references; revise "How It Runs" section; update workspace runtime architecture tables; describe canonical model + key-change hook propagation; replace "drift detection" with "no drift signal"; update CI workflow paths to watch scanner/tarball/CLI/test files; update smoke test tarball validation to exclude .opencode-version and install-opencode.sh.

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

  • cliff-security/cliff#251: Both refactor the in-process remediation executor execution path; main PR removes WorkspaceProcessPool dependency from AgentExecutor, which aligns with the Pydantic AI tool-agent + HITL approval/resume flow introduced in PR #251.
  • cliff-security/cliff#221: Main PR's AgentExecutor.__init__ signature refactoring drops pool parameter to align with the in-process Pydantic AI no-tools wiring introduced in PR #221.
  • cliff-security/cliff#181: Both PRs directly overlap in OpenCode/scanner installer and container packaging: main PR removes scripts/install-opencode.sh and OpenCode copies from docker/Dockerfile, while retrieved PR modifies those same files to preserve LICENSE/NOTICE/third-party attributions.

🐰 Farewell, OpenCode, old friend!
We've shed the subprocess shell,
In-process Pydantic now rings the bell,
No more pools, no templates to tell—
Just models, keys, and hooks that dwell. 🎉

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/pa-drop-opencode-substrate

Comment on lines 19 to 24
# ``ai_model_cache`` is the canonical active model resolved at boot / on
# provider change; fall back to the configured default.
model = (
getattr(request.app.state, "ai_model_cache", None)
or settings.opencode_model
)
# provider change (ADR-0047); empty string when no provider is connected.
model = getattr(request.app.state, "ai_model_cache", None)

# ``ai_env_cache`` is the resolved provider env. A non-empty cache means a
# provider credential is present *and* resolved (vault decrypt succeeded);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/health reads model only from ai_model_cache, so on the vault-missing path in backend/cliff/main.py:213-216 where the cache is cleared/unset, HealthStatus.model returns "" even when a canonical model exists — should we fall back to settings.opencode_model (or DB) when ai_model_cache is None?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
In backend/cliff/api/routes/health.py around lines 19-36, update the model resolution to
fall back to the canonical configured model (e.g., `settings.opencode_model` or DB
source) when `ai_model_cache` is None, so `HealthStatus.model` is never empty when a
model is configured. Also verify backend/cliff/main.py around lines 213-216: the
vault-missing path should either initialize `ai_model_cache` to the canonical model or
avoid clearing it when only provider credentials are missing. Ensure `model=model or ""`
uses the resolved fallback, and keep the existing `opencode_version=substrate_version()`
change intact.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The settings.opencode_model fallback can't be restored — it read opencode.json, which this PR deletes. ai_model_cache is now the single authoritative resolved model.

On the vault-missing path it's empty by construction, and that's correct: a provider can only be connected via BYOK/OAuth, both of which require the vault to store the credential. No vault ⇒ no ai_integration row ⇒ no resolvable model ⇒ model="" and ai_provider_ready=False, which is the honest "AI not configured" state. There's no scenario where a usable canonical model exists but /health.model shows "".

cliffsec status also doesn't depend on /health.model: it reads /api/integrations/ai/status first (DB-backed app_setting(model), no vault needed for the model read) and only falls back to health["model"]. So no code change here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, that makes sense — ai_model_cache is the authoritative source here, and model="" is the correct unconfigured state in the no-vault path. I'll save this to memory once the PR is merged.

@galanko

galanko commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 2, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Comment/docstring-only. The PA code was already correct; the prose still
described OpenCode subprocesses, an auth.json/singleton restart, a process
pool, and opencode.json MCP config that no longer exist. Reword the
load-bearing-wrong comments to describe the in-process Pydantic AI
behaviour (executor dispatch, errors, repo runner, tools, provider/catalog,
ai/service + models, gateway), and drop already-happened "deleted in
PR2.E" / "PR #3 finalizes" future-tense references.

Kept: accurate "used to run on OpenCode / pre-migration" provenance that
explains why code is shaped the way it is, the intentional ``opencode``
HealthStatus/VersionInfo wire-compat field, and the legacy_migration notes
about legacy opencode-keyed app_settings.

Part of PR #3d, stacked on #253.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (7)
backend/tests/test_executor.py (1)

385-399: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

This test no longer exercises the parse-failure path.

AgentExecutor(mock_context_builder) now hits _resolve_active_model() before any remediation-executor output is parsed, so this setup fails on missing AI resolvers rather than on malformed agent output. The assertions still pass, but they no longer protect the regression described in the docstring. Stub _run_pa_executor() here or construct the executor through _executor_with_pa(...) so the failure is driven by bad executor output instead of configuration wiring.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/test_executor.py` around lines 385 - 399, The test currently
exercises configuration wiring because AgentExecutor(mock_context_builder)
triggers _resolve_active_model() before parsing executor output; to restore
coverage of the parse-failure path, prevent _resolve_active_model() from running
by stubbing the executor output instead: either patch/stub
AgentExecutor._run_pa_executor() to return the malformed output that triggers
the parse-failure, or create the executor via the helper _executor_with_pa(...)
which sets up a preconfigured PA executor; update the test setup to patch
"cliff.agents.executor.AgentExecutor._run_pa_executor" (or call
_executor_with_pa) so the failure is driven by bad executor output rather than
missing AI resolvers, keeping the rest of the assertions intact.
backend/cliff/main.py (1)

239-244: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't flip AI readiness to true before the boot probe completes.

_refresh_ai_env_cache(verify=False) currently marks ai_provider_credential_ok = True as soon as a credential resolves, and the boot path calls that before the background verification probe starts. That makes startup report ready during the exact window where a revoked or wrong key has not been authenticated yet, which contradicts the readiness contract documented in this function. The boot warm-cache path needs to keep readiness false until the async probe succeeds, while the post-save hook can still trust its already-validated credential.

Also applies to: 268-277

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/cliff/main.py` around lines 239 - 244, The boot warm-cache path
currently sets app.state.ai_provider_credential_ok = True inside
_refresh_ai_env_cache(verify=False) which flips readiness before the async
verification probe runs; change the logic so _refresh_ai_env_cache(verify=False)
does not set app.state.ai_provider_credential_ok when invoked from the
boot/warm-cache path—leave readiness false until the background probe (the async
verify path invoked later) sets app.state.ai_provider_credential_ok = True on
success; keep the existing behavior for the post-save hook (which calls
_refresh_ai_env_cache with a context that can trust the credential) so that
saving still marks readiness immediate only in that trusted code path.
backend/tests/api/openapi_snapshot.json (1)

4955-4980: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep VersionInfo.opencode required while claiming backward compatibility.

The new description says opencode is retained for a backward-compatible wire shape, but Line 4978 drops it from required. That weakens the /api/version contract exactly where older cliffsec status callers are supposed to keep working.

Suggested contract fix
         "required": [
-          "cliff"
+          "cliff",
+          "opencode"
         ],
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/api/openapi_snapshot.json` around lines 4955 - 4980, The
VersionInfo schema has dropped "opencode" from its required list which breaks
backward-compatible wire shape for /api/version; update the VersionInfo
definition (schema named VersionInfo in the OpenAPI snapshot) to include
"opencode" in the "required" array (i.e., add "opencode" alongside "cliff"),
retain its default/title/type entries, and ensure any code that generates this
openapi snapshot (or the source model used to produce it) marks
VersionInfo.opencode as required so older cliffsec status clients continue to
receive that field.
backend/cliff/workspace/workspace_dir_manager.py (1)

64-85: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Fix misleading GIT_CEILING_DIRECTORIES warning (stale safety claim)

backend/cliff/workspace/workspace_dir_manager.py still warns that agent git operations are “confined via GIT_CEILING_DIRECTORIES” (and references engine/pool.py), but GIT_CEILING_DIRECTORIES is only referenced in comments (no backend runtime code sets/injects it). Meanwhile backend runtime code does spawn git subprocesses (e.g., backend/cliff/assessment/clone.py / backend/cliff/assessment/scope.py / backend/cliff/integrations/github_app/client.py). Update the warning to match the current containment mechanism (workspace isolation/tool gating), or reintroduce explicit GIT_CEILING_DIRECTORIES injection into the in-process git execution path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/cliff/workspace/workspace_dir_manager.py` around lines 64 - 85, The
warning in workspace_dir_manager.py incorrectly claims git operations are
"confined via GIT_CEILING_DIRECTORIES" even though no runtime code sets that env
var; update the logger call (the block using _git_ancestor(base_dir) and the
logger.warning invocation) to either (A) remove/reference
GIT_CEILING_DIRECTORIES as deprecated and instead mention the actual containment
mechanism ("workspace isolation/tool gating") or (B) implement real protection
by injecting GIT_CEILING_DIRECTORIES into the environment for any in-process git
subprocesses (update the subprocess/git invocation sites in
backend/cliff/assessment/clone.py, backend/cliff/assessment/scope.py, and
backend/cliff/integrations/github_app/client.py to add GIT_CEILING_DIRECTORIES
pointing at the intended ceiling directories). Choose one approach and make the
logger message and code consistent with it.
scripts/install-local.sh (1)

205-217: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Preflight bash before invoking the scanner helper.

This block now depends on bash, but the prerequisite scan still only validates curl, tar, git, and gh. On hosts without bash, the install now fails here after the tarball is unpacked and the backend venv is already synced. Add bash to the earlier preflight list so the failure is immediate and actionable.

🔧 Suggested fix
-missing=""
-for cmd in curl tar git; do
+missing=""
+for cmd in curl tar git bash; do

Based on learnings: keep every install-local.sh POSIX-compliant and invoke Bash-specific helpers directly so their shebang takes effect.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/install-local.sh` around lines 205 - 217, Add 'bash' to the preflight
prerequisite checks in scripts/install-local.sh so the script fails early on
systems without bash before unpacking the tarball; update the same list/array
that currently validates curl, tar, git, and gh (e.g., REQUIRED_CMDS or
PRE_REQS) to include bash (and ensure the check uses command -v or which),
leaving the scanner helper invocation (using APP_DIR, BIN_DIR, and calling
"${APP_DIR}/scripts/install-scanners.sh" with CLIFF_SCANNER_VERIFY) unchanged so
its shebang can run when bash is present.
backend/tests/conftest.py (1)

35-42: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Restore the shared FastAPI app after each client fixture.

cliff.main.app is a singleton. This fixture overwrites app.router.lifespan_context and yields without restoring it or clearing mutated app.state, so tests that touch the same app object can leak state into later client cases and become order-dependent.

💡 Minimal containment fix
 `@pytest.fixture`
 def client():
     """FastAPI test client with a no-op lifespan (ADR-0047 — in-process
     substrate, nothing to spawn)."""
     from cliff.main import app

-    app.router.lifespan_context = _noop_lifespan
-    with TestClient(app) as c:
-        yield c
+    original_lifespan = app.router.lifespan_context
+    original_state = {
+        "vault": getattr(app.state, "vault", None),
+        "audit_logger": getattr(app.state, "audit_logger", None),
+        "context_builder": getattr(app.state, "context_builder", None),
+        "process_pool": getattr(app.state, "process_pool", None),
+        "assessment_tasks": getattr(app.state, "assessment_tasks", None),
+    }
+    app.router.lifespan_context = _noop_lifespan
+    try:
+        with TestClient(app) as c:
+            yield c
+    finally:
+        app.router.lifespan_context = original_lifespan
+        for key, value in original_state.items():
+            setattr(app.state, key, value)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/conftest.py` around lines 35 - 42, The client fixture mutates
the singleton cliff.main.app (overwrites app.router.lifespan_context and yields
without cleanup), causing test state leakage; modify the client fixture to save
the original app.router.lifespan_context and a shallow copy of app.state before
assigning _noop_lifespan, then after the TestClient context (or in a finally
block) restore the original lifespan_context and reset app.state to the saved
copy (clearing any keys added during the test) so that the cliff.main.app
singleton is returned to its prior state for subsequent tests; reference the
client fixture, cliff.main.app, _noop_lifespan, app.router.lifespan_context, and
app.state when making the change.
backend/cliff/workspace/context_builder.py (1)

99-128: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Rollback the DB/finding mutation if workspace setup fails.

create_workspace() inserts the row and flips the finding to in_progress before any filesystem/path persistence. If _dir_manager.create(), the manifest write, or update_workspace_dir() fails, you leave behind a workspace row and finding state with no usable directory/path. This needs a compensating cleanup path, or the status flip needs to move after the directory + DB path are durable.

As per coding guidelines, "The WorkspaceContextBuilder orchestrates the directory, finding context, and DB metadata."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/cliff/workspace/context_builder.py` around lines 99 - 128, The
workspace creation currently updates the DB and flips the finding to in_progress
before filesystem and path persistence, so failures in WorkspaceContextBuilder
(e.g., self._dir_manager.create, writing workspace-integrations.json, or
update_workspace_dir) leave a dangling DB row and bad finding state; modify
create_workspace()/WorkspaceContextBuilder so either (A) perform filesystem
creation (self._dir_manager.create) and update_workspace_dir(db, workspace.id,
...) first and only then flip the finding status to in_progress, or (B) add a
compensating rollback path on any exception that deletes the inserted workspace
row and restores the finding state; ensure the rollback targets the same
identifiers used currently (workspace.id, update_workspace_dir, and the
workspace-integrations.json write) and that exceptions from self._mcp_resolver
resolution are still handled without preventing cleanup.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/cliff/api/routes/settings.py`:
- Around line 136-164: Update the list_providers endpoint to return typed
Pydantic models: add response_model=list[ProviderInfo] to the `@router.get`
decorator and change the payload construction to build and return ProviderInfo
instances (not raw dicts). Inside list_providers (which iterates
catalog.all_providers(), catalog.get(provider), and
catalog.picker_options(provider)), convert each provider entry to
ProviderInfo(id=..., name=..., env=[...] or [], models=...) where models is a
mapping from bare model id to the appropriate model schema expected by
ProviderInfo; ensure you import ProviderInfo and any nested model types and
update the payload typing to list[ProviderInfo] before returning it.

In `@backend/cliff/workspace/context_builder.py`:
- Around line 170-182: The update_context path currently writes the filesystem
section (self._dir_manager.write_context_section), appends to agent-runs.jsonl
(AgentRunLog.append) and bumps the DB version (increment_context_version) but
does not persist the structured_output into the SidebarState mirror; add a call
after run_log.append and before increment_context_version to persist the same
structured_output (and section/workspace_id) into the sidebar store so sidebar
reads are fresh — e.g., invoke your SidebarState/SidebarStore persistence method
(via the existing sidebar manager instance, e.g.
self._sidebar_state_manager.persist or similar) with workspace_id, section, and
structured_output to mirror the agent result into SidebarState.

In `@backend/tests/agents/conftest.py`:
- Around line 23-28: The pytest_collection_modifyitems hook currently adds
_skip_no_key to every test under backend/tests/agents and should instead only
skip tests that require a real LLM; update pytest_collection_modifyitems so it
still marks items in the agents directory with pytest.mark.agent but only adds
_skip_no_key when the test item is explicitly marked for live LLMs (e.g., has
marker "live_llm" or "live") or matches the dedicated live-eval test filename
pattern (e.g., contains "eval_" in str(item.fspath)), leaving
FunctionModel/TestModel unit tests unskipped; modify the logic around
pytest_collection_modifyitems and the use of _skip_no_key to check
item.get_closest_marker("live_llm") (or inspect the filename) before calling
item.add_marker(_skip_no_key).

In `@backend/tests/integration/test_workspace_repo_url_overrides_global.py`:
- Around line 24-34: The fixture real_builder currently replaces
app.state.context_builder and never restores it; capture the original builder
(orig = app.state.context_builder) before assigning the new
WorkspaceContextBuilder (created with WorkspaceDirManager and
mcp_resolver=None), yield the new builder as now, and after the yield restore
app.state.context_builder = orig so tests don’t leak the mutated singleton;
update the fixture body to perform this save/restore around the yield while
keeping the WorkspaceContextBuilder and WorkspaceDirManager usage intact.

In `@backend/tests/test_workspace_repo_creation.py`:
- Around line 34-46: The test for create_repo_workspace() verifies the workspace
directory and history folder but misses asserting the presence of the history
log file; update the test around ws_dir (derived from manager.base_dir /
workspace_id) to also assert that (ws_dir / "history" /
"agent-runs.jsonl").exists() so regressions that drop the touched file are
caught; locate the assertions block that checks ws_dir and add this single
assertion referencing agent-runs.jsonl.

In `@CLAUDE.md`:
- Line 35: Update the private cliff-os ADR/IMPL documents to match the new
in-process substrate and canonical AI-state flow described in this PR: modify
ADR 0047-pydantic-ai-substrate.md and ADR 0014-workspace-runtime-architecture.md
(and any related IMPL plans) to reflect the substrate/runtime rewrite and
workspace isolation changes, include the new state flow diagrams and behavioral
details, and add a brief changelog entry so the public CLAUDE.md statements are
consistent with the private source-of-truth before merging.

In `@cli/cliff_cli/daemon.py`:
- Line 505: The doctor/stop checks still treat OpenCode/workspace ports (4096
and 4100-4102) as Cliff-owned; update _cliff_ports() and any hard-coded port
loops (the loop that iterates over ("trivy","semgrep") and the doctor port list)
to remove 4096 and 4100-4102 so those ports are no longer considered
Cliff-owned, and eliminate any remaining references to WorkspaceProcessPool or
the old subprocess substrate in daemon.py that caused those port assumptions.

In `@cli/tests/test_daemon.py`:
- Around line 489-491: The test currently only checks that expected checks are
present via expected.issubset(names) but does not ensure a stale "opencode"
check is absent; update the assertion logic in the test around payload["checks"]
(the names set and expected variables) to explicitly assert that "opencode" is
not in names (e.g., add a negative assertion like "opencode" not in names) or
replace the subset check with an exact-match assertion against the full expected
set to lock the migration contract.

In `@scripts/build-tarball.sh`:
- Around line 14-15: The generated README-LOCAL-INSTALL.md contains stale text
claiming the installer lays down `opencode` binaries; update the README
generation in scripts/build-tarball.sh to remove any references to `opencode`
and change the installer description to reference only the shipped
`install-scanners.sh` (and any shipped files like `scripts/` or
`.scanner-versions`) so the produced README accurately reflects the tarball
contents; locate the README template/emission code in build-tarball.sh (search
for README-LOCAL-INSTALL.md, `opencode`, and `install-scanners.sh`) and edit the
template text to remove the outdated installer wording and replace it with a
concise statement that the tarball includes `install-scanners.sh` for
installation.

---

Outside diff comments:
In `@backend/cliff/main.py`:
- Around line 239-244: The boot warm-cache path currently sets
app.state.ai_provider_credential_ok = True inside
_refresh_ai_env_cache(verify=False) which flips readiness before the async
verification probe runs; change the logic so _refresh_ai_env_cache(verify=False)
does not set app.state.ai_provider_credential_ok when invoked from the
boot/warm-cache path—leave readiness false until the background probe (the async
verify path invoked later) sets app.state.ai_provider_credential_ok = True on
success; keep the existing behavior for the post-save hook (which calls
_refresh_ai_env_cache with a context that can trust the credential) so that
saving still marks readiness immediate only in that trusted code path.

In `@backend/cliff/workspace/context_builder.py`:
- Around line 99-128: The workspace creation currently updates the DB and flips
the finding to in_progress before filesystem and path persistence, so failures
in WorkspaceContextBuilder (e.g., self._dir_manager.create, writing
workspace-integrations.json, or update_workspace_dir) leave a dangling DB row
and bad finding state; modify create_workspace()/WorkspaceContextBuilder so
either (A) perform filesystem creation (self._dir_manager.create) and
update_workspace_dir(db, workspace.id, ...) first and only then flip the finding
status to in_progress, or (B) add a compensating rollback path on any exception
that deletes the inserted workspace row and restores the finding state; ensure
the rollback targets the same identifiers used currently (workspace.id,
update_workspace_dir, and the workspace-integrations.json write) and that
exceptions from self._mcp_resolver resolution are still handled without
preventing cleanup.

In `@backend/cliff/workspace/workspace_dir_manager.py`:
- Around line 64-85: The warning in workspace_dir_manager.py incorrectly claims
git operations are "confined via GIT_CEILING_DIRECTORIES" even though no runtime
code sets that env var; update the logger call (the block using
_git_ancestor(base_dir) and the logger.warning invocation) to either (A)
remove/reference GIT_CEILING_DIRECTORIES as deprecated and instead mention the
actual containment mechanism ("workspace isolation/tool gating") or (B)
implement real protection by injecting GIT_CEILING_DIRECTORIES into the
environment for any in-process git subprocesses (update the subprocess/git
invocation sites in backend/cliff/assessment/clone.py,
backend/cliff/assessment/scope.py, and
backend/cliff/integrations/github_app/client.py to add GIT_CEILING_DIRECTORIES
pointing at the intended ceiling directories). Choose one approach and make the
logger message and code consistent with it.

In `@backend/tests/api/openapi_snapshot.json`:
- Around line 4955-4980: The VersionInfo schema has dropped "opencode" from its
required list which breaks backward-compatible wire shape for /api/version;
update the VersionInfo definition (schema named VersionInfo in the OpenAPI
snapshot) to include "opencode" in the "required" array (i.e., add "opencode"
alongside "cliff"), retain its default/title/type entries, and ensure any code
that generates this openapi snapshot (or the source model used to produce it)
marks VersionInfo.opencode as required so older cliffsec status clients continue
to receive that field.

In `@backend/tests/conftest.py`:
- Around line 35-42: The client fixture mutates the singleton cliff.main.app
(overwrites app.router.lifespan_context and yields without cleanup), causing
test state leakage; modify the client fixture to save the original
app.router.lifespan_context and a shallow copy of app.state before assigning
_noop_lifespan, then after the TestClient context (or in a finally block)
restore the original lifespan_context and reset app.state to the saved copy
(clearing any keys added during the test) so that the cliff.main.app singleton
is returned to its prior state for subsequent tests; reference the client
fixture, cliff.main.app, _noop_lifespan, app.router.lifespan_context, and
app.state when making the change.

In `@backend/tests/test_executor.py`:
- Around line 385-399: The test currently exercises configuration wiring because
AgentExecutor(mock_context_builder) triggers _resolve_active_model() before
parsing executor output; to restore coverage of the parse-failure path, prevent
_resolve_active_model() from running by stubbing the executor output instead:
either patch/stub AgentExecutor._run_pa_executor() to return the malformed
output that triggers the parse-failure, or create the executor via the helper
_executor_with_pa(...) which sets up a preconfigured PA executor; update the
test setup to patch "cliff.agents.executor.AgentExecutor._run_pa_executor" (or
call _executor_with_pa) so the failure is driven by bad executor output rather
than missing AI resolvers, keeping the rest of the assertions intact.

In `@scripts/install-local.sh`:
- Around line 205-217: Add 'bash' to the preflight prerequisite checks in
scripts/install-local.sh so the script fails early on systems without bash
before unpacking the tarball; update the same list/array that currently
validates curl, tar, git, and gh (e.g., REQUIRED_CMDS or PRE_REQS) to include
bash (and ensure the check uses command -v or which), leaving the scanner helper
invocation (using APP_DIR, BIN_DIR, and calling
"${APP_DIR}/scripts/install-scanners.sh" with CLIFF_SCANNER_VERIFY) unchanged so
its shebang can run when bash is present.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: ee62b455-cade-4cbc-a7ec-3bfd95e50146

📥 Commits

Reviewing files that changed from the base of the PR and between 08cf81b and c69b1fa.

📒 Files selected for processing (86)
  • .github/workflows/install-smoke.yml
  • .opencode-version
  • .opencode/agents/security-orchestrator.md
  • CLAUDE.md
  • backend/cliff/agents/__init__.py
  • backend/cliff/agents/executor.py
  • backend/cliff/agents/template_engine.py
  • backend/cliff/agents/templates/dependabot_config_generator.md.j2
  • backend/cliff/agents/templates/enricher.md.j2
  • backend/cliff/agents/templates/evidence_collector.md.j2
  • backend/cliff/agents/templates/exposure_analyzer.md.j2
  • backend/cliff/agents/templates/orchestrator.md.j2
  • backend/cliff/agents/templates/owner_resolver.md.j2
  • backend/cliff/agents/templates/remediation_executor.md.j2
  • backend/cliff/agents/templates/remediation_planner.md.j2
  • backend/cliff/agents/templates/security_md_generator.md.j2
  • backend/cliff/agents/templates/validation_checker.md.j2
  • backend/cliff/ai/service.py
  • backend/cliff/api/_engine_dep.py
  • backend/cliff/api/routes/health.py
  • backend/cliff/api/routes/settings.py
  • backend/cliff/api/routes/version.py
  • backend/cliff/config.py
  • backend/cliff/engine/__init__.py
  • backend/cliff/engine/client.py
  • backend/cliff/engine/config_manager.py
  • backend/cliff/engine/models.py
  • backend/cliff/engine/pool.py
  • backend/cliff/engine/process.py
  • backend/cliff/main.py
  • backend/cliff/models/__init__.py
  • backend/cliff/workspace/context_builder.py
  • backend/cliff/workspace/workspace_dir.py
  • backend/cliff/workspace/workspace_dir_manager.py
  • backend/tests/agents/conftest.py
  • backend/tests/agents/fixtures/plain_description_evals.json
  • backend/tests/agents/test_deferred_tools_persist.py
  • backend/tests/agents/test_executor_provider_chain.py
  • backend/tests/api/openapi_snapshot.json
  • backend/tests/conftest.py
  • backend/tests/e2e/__init__.py
  • backend/tests/e2e/conftest.py
  • backend/tests/e2e/test_agent_pipeline_e2e.py
  • backend/tests/e2e/test_health_e2e.py
  • backend/tests/e2e/test_process_pool_e2e.py
  • backend/tests/e2e/test_repo_workspace_agents.py
  • backend/tests/e2e/test_settings_e2e.py
  • backend/tests/integration/test_permission_flow.py
  • backend/tests/integration/test_rate_limit_backoff.py
  • backend/tests/integration/test_workspace_npm_cache_isolation.py
  • backend/tests/integration/test_workspace_repo_url_overrides_global.py
  • backend/tests/test_agent_template_engine.py
  • backend/tests/test_ai_phase_f_wiring.py
  • backend/tests/test_ai_service.py
  • backend/tests/test_config.py
  • backend/tests/test_config_manager.py
  • backend/tests/test_context_builder.py
  • backend/tests/test_docker.py
  • backend/tests/test_engine_client.py
  • backend/tests/test_engine_process.py
  • backend/tests/test_executor.py
  • backend/tests/test_gateway.py
  • backend/tests/test_mcp_gateway_integration.py
  • backend/tests/test_permission_client.py
  • backend/tests/test_process_pool.py
  • backend/tests/test_repo_action_templates.py
  • backend/tests/test_routes_ai_integrations.py
  • backend/tests/test_routes_ai_openrouter.py
  • backend/tests/test_routes_settings.py
  • backend/tests/test_routes_version.py
  • backend/tests/test_workspace_dir.py
  • backend/tests/test_workspace_integrations.py
  • backend/tests/test_workspace_repo_creation.py
  • cli/cliff_cli/daemon.py
  • cli/cliff_cli/updater.py
  • cli/tests/test_daemon.py
  • cli/tests/test_updater.py
  • docker/Dockerfile
  • frontend/src/api/client.ts
  • frontend/src/api/hooks.ts
  • frontend/src/test/msw/sessionHandlers.ts
  • opencode.json
  • scripts/build-tarball.sh
  • scripts/install-local.sh
  • scripts/install-opencode.sh
  • tests/install/smoke.sh
💤 Files with no reviewable changes (51)
  • .opencode/agents/security-orchestrator.md
  • backend/cliff/agents/templates/evidence_collector.md.j2
  • backend/cliff/agents/templates/remediation_planner.md.j2
  • scripts/install-opencode.sh
  • opencode.json
  • .opencode-version
  • backend/cliff/agents/templates/exposure_analyzer.md.j2
  • backend/cliff/agents/templates/dependabot_config_generator.md.j2
  • .github/workflows/install-smoke.yml
  • backend/tests/test_engine_client.py
  • backend/cliff/workspace/workspace_dir.py
  • backend/tests/e2e/test_health_e2e.py
  • backend/cliff/engine/init.py
  • backend/cliff/engine/models.py
  • tests/install/smoke.sh
  • backend/cliff/engine/client.py
  • backend/tests/test_agent_template_engine.py
  • backend/cliff/agents/templates/security_md_generator.md.j2
  • backend/cliff/agents/templates/owner_resolver.md.j2
  • backend/tests/test_config_manager.py
  • backend/cliff/agents/templates/validation_checker.md.j2
  • backend/cliff/engine/process.py
  • frontend/src/test/msw/sessionHandlers.ts
  • backend/tests/e2e/test_agent_pipeline_e2e.py
  • backend/tests/e2e/test_settings_e2e.py
  • backend/tests/test_engine_process.py
  • backend/cliff/agents/templates/orchestrator.md.j2
  • backend/cliff/agents/templates/enricher.md.j2
  • backend/cliff/api/_engine_dep.py
  • backend/tests/test_repo_action_templates.py
  • backend/tests/test_routes_ai_openrouter.py
  • backend/tests/test_permission_client.py
  • backend/tests/e2e/test_process_pool_e2e.py
  • backend/tests/integration/test_rate_limit_backoff.py
  • backend/cliff/agents/template_engine.py
  • backend/tests/agents/test_deferred_tools_persist.py
  • backend/cliff/engine/config_manager.py
  • backend/tests/test_process_pool.py
  • backend/cliff/agents/templates/remediation_executor.md.j2
  • frontend/src/api/client.ts
  • frontend/src/api/hooks.ts
  • backend/tests/integration/test_workspace_npm_cache_isolation.py
  • backend/tests/e2e/test_repo_workspace_agents.py
  • backend/tests/test_ai_phase_f_wiring.py
  • backend/tests/test_workspace_dir.py
  • backend/tests/agents/test_executor_provider_chain.py
  • backend/tests/e2e/conftest.py
  • backend/cliff/engine/pool.py
  • backend/tests/test_routes_ai_integrations.py
  • cli/tests/test_updater.py
  • backend/tests/test_gateway.py

Comment thread backend/cliff/api/routes/settings.py Outdated
Comment on lines +136 to +164
@router.get("/settings/providers")
async def list_providers():
try:
return await config_manager.list_available_providers()
except Exception as exc:
raise HTTPException(status_code=502, detail=f"OpenCode unavailable: {exc}") from exc

"""Return the supported-provider catalog (ADR-0037).

@router.get("/settings/providers/configured")
async def get_configured_providers():
try:
providers = await config_manager.get_configured_providers()
auth = await config_manager.get_auth_status()
return {"providers": providers, "auth": auth}
except Exception as exc:
raise HTTPException(status_code=502, detail=f"OpenCode unavailable: {exc}") from exc
Built from the static :mod:`cliff.ai.catalog` — one entry per
provider with its key env var and the curated model picker rows. The
wire shape (``{id, name, env, models}`` with ``models`` keyed by the
bare model id) is what the Settings model picker and ``cliffsec model
list`` consume.
"""
payload: list[dict] = []
for provider in catalog.all_providers():
info = catalog.get(provider)
models: dict[str, dict] = {}
for opt in catalog.picker_options(provider):
# The picker id is the full ``<provider>/<model>`` id; key by
# the bare model id so ``f"{provider}/{model_id}"`` round-trips
# (the UI and CLI both rebuild the full id that way).
bare = opt.id.split("/", 1)[1] if "/" in opt.id else opt.id
models[bare] = {"id": bare, "name": opt.label}
payload.append(
{
"id": provider,
"name": info.docs_label,
"env": [info.env_var_name] if info.env_var_name else [],
"models": models,
}
)
return payload

@coderabbitai coderabbitai Bot Jun 2, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Restore a typed API contract for /settings/providers.

Returning raw dicts here drops FastAPI response validation and weakens the generated OpenAPI schema for a user-facing endpoint, even though ProviderInfo already models this shape. Please add response_model=list[ProviderInfo] and return ProviderInfo(...) instances so the CLI/frontend keep a stable contract.

🧩 Suggested fix
 from cliff.models import (
     CredentialCreate,
     CredentialInfo,
     IntegrationConfig,
     IntegrationConfigCreate,
     IntegrationConfigUpdate,
     IntegrationHealthStatus,
     ModelConfig,
     ModelUpdateRequest,
+    ProviderInfo,
     TestConnectionResult,
 )
@@
-@router.get("/settings/providers")
-async def list_providers():
+@router.get("/settings/providers", response_model=list[ProviderInfo])
+async def list_providers() -> list[ProviderInfo]:
@@
-    payload: list[dict] = []
+    payload: list[ProviderInfo] = []
@@
-        payload.append(
-            {
-                "id": provider,
-                "name": info.docs_label,
-                "env": [info.env_var_name] if info.env_var_name else [],
-                "models": models,
-            }
-        )
+        payload.append(
+            ProviderInfo(
+                id=provider,
+                name=info.docs_label,
+                env=[info.env_var_name] if info.env_var_name else [],
+                models=models,
+            )
+        )

As per coding guidelines: **/*.py: Use Pydantic models for data structures and API schemas in the backend.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/cliff/api/routes/settings.py` around lines 136 - 164, Update the
list_providers endpoint to return typed Pydantic models: add
response_model=list[ProviderInfo] to the `@router.get` decorator and change the
payload construction to build and return ProviderInfo instances (not raw dicts).
Inside list_providers (which iterates catalog.all_providers(),
catalog.get(provider), and catalog.picker_options(provider)), convert each
provider entry to ProviderInfo(id=..., name=..., env=[...] or [], models=...)
where models is a mapping from bare model id to the appropriate model schema
expected by ProviderInfo; ensure you import ProviderInfo and any nested model
types and update the payload typing to list[ProviderInfo] before returning it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commit 8b29957 addressed this comment by importing ProviderInfo, adding response_model=list[ProviderInfo], typing the payload as list[ProviderInfo], and populating it with ProviderInfo instances so the endpoint returns typed Pydantic models.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipped: comment is from another GitHub bot.

Comment on lines 170 to 182
# 1. Write context section (auto-regenerates CONTEXT.md)
self._dir_manager.write_context_section(workspace_id, section, structured_output)

# 2. Re-render agent templates with full updated context
ws_dir = self._dir_manager.get(workspace_id)
if ws_dir is None:
raise FileNotFoundError(f"Workspace directory not found: {workspace_id}")

finding_data = json.loads(ws_dir.finding_json.read_text())
all_context = self._dir_manager.read_all_context(workspace_id)
self._template_engine.write_agents(
ws_dir.agents_dir, finding=finding_data, **all_context
)

# 3. Log to agent-runs.jsonl
# 2. Log to agent-runs.jsonl
run_log = AgentRunLog(ws_dir.agent_runs_log)
run_log.append(agent_type=agent_type, status="completed", summary=summary)

# 4. Bump version in DB
# 3. Bump version in DB
new_version = await increment_context_version(db, workspace_id)

@coderabbitai coderabbitai Bot Jun 2, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Persist agent output into SidebarState in this write path.

update_context() now only writes the filesystem section, appends agent-runs.jsonl, and bumps context_version. That drops the SidebarState mirror, so sidebar/Issues-page flows can read stale data even though the workspace files were updated. The structured output needs to be persisted to the sidebar store here as part of the same agent-result path.

As per coding guidelines, "Every agent result must persist into both the chat timeline AND the SidebarState. Never only save to chat." Also, "Every user-facing action follows the interaction grammar: ask -> run -> summarize -> persist -> decide next."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/cliff/workspace/context_builder.py` around lines 170 - 182, The
update_context path currently writes the filesystem section
(self._dir_manager.write_context_section), appends to agent-runs.jsonl
(AgentRunLog.append) and bumps the DB version (increment_context_version) but
does not persist the structured_output into the SidebarState mirror; add a call
after run_log.append and before increment_context_version to persist the same
structured_output (and section/workspace_id) into the sidebar store so sidebar
reads are fresh — e.g., invoke your SidebarState/SidebarStore persistence method
(via the existing sidebar manager instance, e.g.
self._sidebar_state_manager.persist or similar) with workspace_id, section, and
structured_output to mirror the agent result into SidebarState.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

update_context() never wrote SidebarState — and this PR didn't change that. The dual-persist (context + sidebar) happens in the executor: AgentExecutor.execute() calls map_and_upsert(...) (backend/cliff/agents/executor.py:1015) to mirror the structured output into SidebarState, then calls context_builder.update_context() for the filesystem context + version bump. The only thing this PR removed from update_context was the dead template_engine.write_agents() re-render. So the dual-persist invariant is intact; no change needed here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@galanko, you're right — I misread the responsibility boundary. update_context() is only a filesystem + version-bump helper; the dual-persist (sidebar mirror via map_and_upsert + filesystem via context_builder.update_context()) is orchestrated one level up in AgentExecutor.execute(). Removing template_engine.write_agents() didn't touch that invariant at all. Apologies for the noise.


✏️ Learnings added
Learnt from: galanko
Repo: cliff-security/cliff PR: 254
File: backend/cliff/workspace/context_builder.py:170-182
Timestamp: 2026-06-02T21:40:07.231Z
Learning: In cliff-security/cliff, the dual-persist invariant (agent result → SidebarState + filesystem context) is orchestrated at the `AgentExecutor.execute()` level: it calls `map_and_upsert(...)` (around backend/cliff/agents/executor.py:1015) to mirror structured output into SidebarState, and then calls `context_builder.update_context()` for the filesystem section write + context_version DB bump. `WorkspaceContextBuilder.update_context()` is only responsible for filesystem I/O and version tracking — it never has been responsible for SidebarState persistence. Do not flag `update_context()` for missing SidebarState writes.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: galanko
Repo: cliff-security/cliff PR: 251
File: backend/cliff/api/routes/agent_execution.py:518-518
Timestamp: 2026-06-01T14:31:02.717Z
Learning: In this repo, Ruff is configured without the `ANN` ruleset (it only selects `E,F,I,N,UP,B,SIM,TCH`). During review, do not flag missing type annotations when they occur as part of the existing convention: (1) FastAPI/DI route handler parameters representing a database dependency (e.g., `db=Depends(get_db)`), and (2) test helpers/fixtures. Treat adding `ANN`-style annotations for these elements as out of scope for individual feature PRs; only flag missing/incorrect typing where the repo already enforces it via other rules or where the code explicitly violates existing typing conventions.

Comment on lines 23 to 28
def pytest_collection_modifyitems(items):
"""Mark all items in this directory as agent tests and apply skip conditions."""
"""Mark all items in this directory as agent tests and skip when no key."""
for item in items:
if "/agents/" in str(item.fspath):
item.add_marker(pytest.mark.agent)
item.add_marker(_skip_no_binary)
item.add_marker(_skip_no_key)

@coderabbitai coderabbitai Bot Jun 2, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't skip the entire agent test directory when no API key is present.

This hook adds _skip_no_key to every test under backend/tests/agents, even though the module docstring says most of these tests run against FunctionModel/TestModel and need no network. In keyless CI/dev runs, that masks regressions in the in-process agent runtime instead of only skipping the live eval(s) that actually call a real LLM. Gate the skip behind an explicit live-LLM marker or the specific eval test file instead of the whole directory. Based on learnings: "Every phase must have tests passing before it is considered complete. Backend agent tests should use FunctionModel/TestModel."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/agents/conftest.py` around lines 23 - 28, The
pytest_collection_modifyitems hook currently adds _skip_no_key to every test
under backend/tests/agents and should instead only skip tests that require a
real LLM; update pytest_collection_modifyitems so it still marks items in the
agents directory with pytest.mark.agent but only adds _skip_no_key when the test
item is explicitly marked for live LLMs (e.g., has marker "live_llm" or "live")
or matches the dedicated live-eval test filename pattern (e.g., contains "eval_"
in str(item.fspath)), leaving FunctionModel/TestModel unit tests unskipped;
modify the logic around pytest_collection_modifyitems and the use of
_skip_no_key to check item.get_closest_marker("live_llm") (or inspect the
filename) before calling item.add_marker(_skip_no_key).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commit 8b29957 addressed this comment by limiting _skip_no_key to the specific live-LLM files (e.g., the known live evals) while still marking all agent tests, ensuring FunctionModel/TestModel tests remain runnable without a key.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipped: comment is from another GitHub bot.

Comment on lines 24 to 34
@pytest.fixture
async def real_builder(db_client, tmp_path):
"""Swap the conftest mock for a REAL WorkspaceContextBuilder rooted at
``tmp_path`` so ``create_workspace`` actually persists ``repo_url`` and
writes agent templates we can grep.
``tmp_path`` so ``create_workspace`` actually persists ``repo_url``.
"""
from cliff.main import app

dir_manager = WorkspaceDirManager(base_dir=tmp_path)
template_engine = AgentTemplateEngine()
real = WorkspaceContextBuilder(dir_manager, template_engine, mcp_resolver=None)
real = WorkspaceContextBuilder(dir_manager, mcp_resolver=None)
app.state.context_builder = real
yield real

@coderabbitai coderabbitai Bot Jun 2, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Restore the original app.state.context_builder after the fixture.

This fixture mutates the singleton FastAPI app and never resets it, so later tests can inherit a builder rooted at a previous tmp_path and become order-dependent.

💡 Suggested fix
 `@pytest.fixture`
 async def real_builder(db_client, tmp_path):
     """Swap the conftest mock for a REAL WorkspaceContextBuilder rooted at
     ``tmp_path`` so ``create_workspace`` actually persists ``repo_url``.
     """
     from cliff.main import app

     dir_manager = WorkspaceDirManager(base_dir=tmp_path)
     real = WorkspaceContextBuilder(dir_manager, mcp_resolver=None)
-    app.state.context_builder = real
-    yield real
+    previous = app.state.context_builder
+    app.state.context_builder = real
+    try:
+        yield real
+    finally:
+        app.state.context_builder = previous
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/integration/test_workspace_repo_url_overrides_global.py` around
lines 24 - 34, The fixture real_builder currently replaces
app.state.context_builder and never restores it; capture the original builder
(orig = app.state.context_builder) before assigning the new
WorkspaceContextBuilder (created with WorkspaceDirManager and
mcp_resolver=None), yield the new builder as now, and after the yield restore
app.state.context_builder = orig so tests don’t leak the mutated singleton;
update the fixture body to perform this save/restore around the yield while
keeping the WorkspaceContextBuilder and WorkspaceDirManager usage intact.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commit 8b29957 addressed this comment by saving the prior app.state.context_builder, yielding the temporary builder, and restoring the original builder in a finally block so the singleton no longer leaks between tests.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipped: comment is from another GitHub bot.

Comment on lines 34 to +46
ws_dir = manager.base_dir / workspace_id
assert ws_dir.is_dir()
assert (ws_dir / ".opencode" / "agents").is_dir()
assert (ws_dir / "history").is_dir()
assert (ws_dir / "opencode.json").is_file()

# No finding-scoped files on a repo workspace.
assert not (ws_dir / "finding.json").exists()
assert not (ws_dir / "finding.md").exists()
assert not (ws_dir / "CONTEXT.md").exists()

# No OpenCode scaffolding — the PA repo-action agent carries its own
# prompt + permission policy (ADR-0047); nothing is rendered to disk.
assert not (ws_dir / ".opencode").exists()
assert not (ws_dir / "opencode.json").exists()

@coderabbitai coderabbitai Bot Jun 2, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Assert history/agent-runs.jsonl exists too.

create_repo_workspace() now explicitly touches history/agent-runs.jsonl so repo and finding workspaces share the same log contract. This test only checks the directory, so a regression dropping the file would go unnoticed.

💡 Suggested assertion
         ws_dir = manager.base_dir / workspace_id
         assert ws_dir.is_dir()
         assert (ws_dir / "history").is_dir()
+        assert (ws_dir / "history" / "agent-runs.jsonl").is_file()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/test_workspace_repo_creation.py` around lines 34 - 46, The test
for create_repo_workspace() verifies the workspace directory and history folder
but misses asserting the presence of the history log file; update the test
around ws_dir (derived from manager.base_dir / workspace_id) to also assert that
(ws_dir / "history" / "agent-runs.jsonl").exists() so regressions that drop the
touched file are caught; locate the assertions block that checks ws_dir and add
this single assertion referencing agent-runs.jsonl.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commit 8b29957 addressed this comment by adding an assertion that history/agent-runs.jsonl exists, ensuring the test now verifies the file presence as requested.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipped: comment is from another GitHub bot.

Comment thread CLAUDE.md
| Deployment | Single Docker container, port 8000 | `docker/` |

See `cliff-os/docs/architecture/overview.md` (private) for the full system diagram and `cliff-os/docs/adr/0014-workspace-runtime-architecture.md` (private) for the workspace isolation architecture.
See `cliff-os/docs/architecture/overview.md` (private) for the full system diagram, `cliff-os/docs/adr/0047-pydantic-ai-substrate.md` (private) for the substrate, and `cliff-os/docs/adr/0014-workspace-runtime-architecture.md` (private) for the workspace isolation architecture.

@coderabbitai coderabbitai Bot Jun 2, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Update the private cliff-os ADR/IMPL docs in this change.

This file now documents the new in-process substrate and canonical AI-state flow, but the PR notes say the matching cliff-os docs are deferred. For a substrate/runtime rewrite of this size, leaving the private architecture source of truth stale should block merge.

As per coding guidelines, "All architectural changes must be documented via Architecture Decision Records (ADRs) in the private cliff-os/docs/adr/ directory. When behavior documented in an ADR or IMPL plan changes, update that documentation in the same change."

Also applies to: 210-220

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CLAUDE.md` at line 35, Update the private cliff-os ADR/IMPL documents to
match the new in-process substrate and canonical AI-state flow described in this
PR: modify ADR 0047-pydantic-ai-substrate.md and ADR
0014-workspace-runtime-architecture.md (and any related IMPL plans) to reflect
the substrate/runtime rewrite and workspace isolation changes, include the new
state flow diagrams and behavioral details, and add a brief changelog entry so
the public CLAUDE.md statements are consistent with the private source-of-truth
before merging.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed the private cliff-os ADR/IMPL-0022 docs need the same update — but that's the architect's surface in the private umbrella repo, not this public code PR (per the repo workflow, cliff-os docs are updated via an architect pass, and this PR is build-mode). It's tracked as a follow-up alongside the cliff-os IMPL-0022 status update. The public CLAUDE.md in this PR is the user-facing source and is consistent with the new substrate.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@galanko, that makes sense — the build/architect role separation is clear, and tracking it against IMPL-0022 is the right place for it. I'll defer to that workflow. No further action needed on this comment.

Comment thread cli/cliff_cli/daemon.py
Comment thread cli/tests/test_daemon.py
Comment thread scripts/build-tarball.sh
Comment on lines +14 to 15
# scripts/ install-scanners.sh
# .scanner-versions

@coderabbitai coderabbitai Bot Jun 2, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Remove the stale OpenCode install text from the shipped README.

Line 116 still says the installer lays down opencode binaries even though this tarball now ships only install-scanners.sh. That makes the generated README-LOCAL-INSTALL.md false for every archive built from this script.

Suggested cleanup
-# ---- scripts (just the two install helpers — install-local.sh isn't needed
+# ---- scripts (just the scanner install helper — install-local.sh isn't needed
 # at runtime, it's how the user got here in the first place) -----------------
@@
-sets up a uv-managed Python venv, installs the opencode/trivy/semgrep
-binaries via the bundled scripts, and drops `cliffsec` into ~/.local/bin/.
+sets up a uv-managed Python venv, installs the trivy/semgrep
+binaries via the bundled script, and drops `cliffsec` into ~/.local/bin/.

Also applies to: 96-117

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/build-tarball.sh` around lines 14 - 15, The generated
README-LOCAL-INSTALL.md contains stale text claiming the installer lays down
`opencode` binaries; update the README generation in scripts/build-tarball.sh to
remove any references to `opencode` and change the installer description to
reference only the shipped `install-scanners.sh` (and any shipped files like
`scripts/` or `.scanner-versions`) so the produced README accurately reflects
the tarball contents; locate the README template/emission code in
build-tarball.sh (search for README-LOCAL-INSTALL.md, `opencode`, and
`install-scanners.sh`) and edit the template text to remove the outdated
installer wording and replace it with a concise statement that the tarball
includes `install-scanners.sh` for installation.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commit 8b29957 addressed this comment by updating the build script’s README template to describe only the scanner installer and by removing the outdated reference to installing the opencode binaries, ensuring the generated README now matches the shipped tarball contents.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipped: comment is from another GitHub bot.

galanko and others added 3 commits June 3, 2026 00:38
- settings.py: GET /settings/providers gets response_model=list[ProviderInfo]
  and returns typed instances — restores FastAPI validation + OpenAPI schema
  for a user-facing endpoint (CodeRabbit).
- tests/agents/conftest.py: skip-no-key now applies ONLY to the two live-LLM
  files (test_normalizer_agent, test_plain_description_eval); the FunctionModel/
  TestModel runtime tests run in keyless CI so a regression isn't masked
  (CodeRabbit). Verified: keyless run → FunctionModel tests run, live skip.
- test_workspace_repo_url_overrides_global.py: save/restore
  app.state.context_builder so the fixture can't leak a tmp_path-rooted
  builder into later tests (CodeRabbit).
- test_workspace_repo_creation.py: also assert history/agent-runs.jsonl exists
  (CodeRabbit).
- build-tarball.sh: drop the stale "opencode binaries" text from the generated
  README-LOCAL-INSTALL.md (CodeRabbit).
- test_ai_catalog.py: docstring no longer references "spawning OpenCode".

Part of PR #3d, stacked on #253.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The agent substrate runs in-process via Pydantic AI (ADR-0047), so the CLI
no longer manages any child agent process or its ports:

- process_sweep.py: drop the ``opencode`` process kind, the
  OPENCODE_SINGLETON_PORT (4096) + WORKSPACE_PORT_RANGE (4100-4199)
  constants, and the bin/opencode classification arm. find_cliff_processes()
  is uvicorn-only and no longer needs cliff_home.
- daemon.py: _cliff_ports() + the doctor port loop only consider the app
  port now; stop/uninstall stop scanning the dead 4096/4100-4199 range;
  layout docstring drops .opencode-version + bin/opencode.
- cli.py: drop the now-unreachable ``opencode_engine_unavailable`` status
  blocker (/health.opencode is hard-wired "ok").
- updater.py docstring no longer claims install-opencode.sh.

Tests updated: test_process_sweep (uvicorn-only), test_daemon (app-port
sweep, opencode→uvicorn fixtures), test_cli (unconfigured blockers). CLI
suite 94 passed, ruff clean. Zero OpenCode references remain in cli/cliff_cli.

Part of PR #3d, stacked on #253.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CodeRabbit: lock the migration contract — the doctor payload must no longer
emit an ``opencode`` check (the binary version check was removed with the
substrate). Adds the explicit negative assertions alongside the subset check.

Part of PR #3d, stacked on #253.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment on lines 116 to 119
has_uvicorn = any("uvicorn" in arg for arg in cmdline)
has_app = any("cliff.main:app" in arg for arg in cmdline)
if has_uvicorn and has_app:
return "uvicorn"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_classify() only recognizes uvicorn + cliff.main:app, so find_cliff_processes() misses legacy opencode children and stop_cmd()/uninstall_cmd() can think Cliff is gone while it still runs — should we keep the opencode match or add a migration cleanup path for old installs?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
cli/cliff_cli/process_sweep.py around lines 106-121 in `_classify()`, you removed the
legacy OpenCode `opencode` ownership matching and now only return "uvicorn" when the
cmdline contains both `uvicorn` and `cliff.main:app`. Update `_classify()` to also
recognize the legacy `opencode` child processes from pre-ADR-0047 installs using the
same safety contract (match a distinctive cmdline token or the resolved
`cliff_home/bin/opencode` path), and return kind="opencode" for those processes. Then in
`find_cliff_processes()` around lines 137-167, ensure the function computes whatever is
needed (e.g., pass `cliff_home`/`opencode_bin` or otherwise derive the legacy match
input) and that the downstream callers like `stop_cmd()`/`uninstall_cmd()` can see and
act on `FoundProcess(kind="opencode")` for legacy cleanup during in-place
upgrade/uninstall.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional — this is a clean break, not a regression. The OpenCode binary is no longer installed or spawned post-ADR-0047, so there are no opencode children for a healthy install to reap. The only way to have a leftover orphan is to force-kill the parent of a pre-upgrade install and then upgrade the CLI without stopping first — a rare one-time edge where the orphan sits idle on a port the new app never binds (no functional impact; pkill opencode clears it). Re-adding permanent opencode awareness to carry that one-time edge forever runs against the whole point of the migration, so I'm leaving the sweep uvicorn-only.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it — thanks for the context. I’ll save this to memory once the PR is merged.

Comment thread cli/cliff_cli/daemon.py
Comment on lines +548 to 552
# Portonly the app port matters now (the substrate is in-process).
for port in (DEFAULT_PORT,):
free = _port_free(port)
warn_only = port != DEFAULT_PORT
checks.append(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doctor only probes DEFAULT_PORT here, but _configured_app_port() is the bind-port source of truth — should we iterate over that instead so non-default CLIFF_APP_PORT values don't miss port-in-use failures?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
cli/cliff_cli/daemon.py around lines 548-554 inside _gather_doctor_checks(), update the
“# Port — only the app port matters now” logic to probe the real configured app
port instead of hardcoding DEFAULT_PORT. Refactor the loop to iterate over
_cliff_ports() (or directly over _configured_app_port()) so doctor reports in-use
correctly when CLIFF_APP_PORT overrides the default. Preserve warn_only behavior if
needed, but ensure the checked port set matches what start_cmd/stop_cmd/_cliff_ports()
actually bind.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 6c-latest: the doctor port loop now iterates _cliff_ports() (the configured CLIFF_APP_PORT) instead of the hard-coded DEFAULT_PORT, so a non-default deployment no longer misses a port-in-use failure. Added a regression test (test_doctor_probes_configured_app_port).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commit 398bdf8 addressed this comment by refactoring the doctor port loop to iterate over _cliff_ports() so the configured CLIFF_APP_PORT is probed, and by adding test_doctor_probes_configured_app_port to confirm non-default ports are checked instead of the hard-coded default.

galanko and others added 3 commits June 3, 2026 11:40
Adding response_model=list[ProviderInfo] to GET /settings/providers (PR
review fix) changes the schema's response component. Re-snapshot to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Baz: the doctor port check hard-coded DEFAULT_PORT, so a deployment with a
non-default CLIFF_APP_PORT would miss a real port-in-use failure (and report
8000 free while 9123 is wedged). Iterate over _cliff_ports() (the configured
app port) instead. Adds a regression test.

Part of PR #3d, stacked on #253.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
After the e2e verification passed, clean the user-facing and operational
references the substrate removal left behind:

- README: installer no longer fetches an OpenCode binary; bundled
  subprocesses are now just Trivy + Semgrep (PA is a Python dep).
- NOTICE + THIRD-PARTY-LICENSES.md: drop the OpenCode (MIT) bundled-binary
  entry — we no longer download or redistribute it.
- .dockerignore / .gitignore / CONTRIBUTING.md / scripts/dev.sh /
  scripts/install-local.sh: drop the .opencode-version + install-opencode.sh
  references.
- docker/Dockerfile + entrypoint.sh: drop the "OpenCode Go binary" stage
  comment and the CLIFF_OPENCODE_PORT/BIN runtime banner lines.
- KNOWN_ISSUES.md: remove the 9 now-resolved OpenCode-era issues (SSE/chat
  sessions, opencode.json drift, keys-lost-on-restart, 75+-provider catalog)
  and reword the orphan-PR issue's failure modes.
- plugins/cliff-security knowledge: install/doctor no longer mention an
  opencode binary or the retired 4096/4100-4102 port checks.
- frontend: PostureCard no longer shows "spinning up OpenCode…"; regenerate
  api/types.ts from the current OpenAPI snapshot (drops the deleted
  session/chat endpoints); reword the AIProviderStatus / ModelPicker
  comments + a test fixture.

Left intact: CHANGELOG.md + ROADMAP.md completed-phase milestones (accurate
history — Phase 1 genuinely was an OpenCode spike), the CLAUDE.md historical
-names note, and the intentional ``opencode``/``opencode_version`` HealthStatus
wire-compat fields.

Verified: frontend tsc + lint + build clean. (The pre-existing frontend
vitest failures live on #253 too and are uncaught by CI, which runs
lint+build only — out of scope for this PR.)

Part of PR #3d, stacked on #253.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@galanko galanko merged commit 37bbb4d into feat/pa-migrate-normalizer Jun 3, 2026
2 checks passed
@galanko galanko deleted the feat/pa-drop-opencode-substrate branch June 3, 2026 09:06
galanko added a commit that referenced this pull request Jun 3, 2026
… substrate removal (PR #3b–#3d) (#253)

* feat(agents): migrate the finding normalizer to a Pydantic AI app-level agent

IMPL-0022 PR #3b — the second OpenCode consumer off the substrate. The
finding normalizer is a single LLM call (raw scanner JSON → normalized
findings); it now runs in-process through Pydantic AI instead of the
singleton OpenCode process.

- add `cliff/agents/runtime/normalizer_agent.py` — `NormalizedFinding`
  (a deliberately lenient output schema) + `build_normalizer_agent`, with
  `output_type=list[NormalizedFinding]`, no deps/tools. App-level, so the
  prompt is passed in by the caller.
- rewrite `integrations/normalizer.py`: `normalize_findings(source, raw, *,
  env, model)` builds the model via the runtime provider factory and calls
  `agent.run()`. Pydantic AI's structured output + internal retries replace
  the hand-rolled `_call_llm_with_retry` / `_extract_json_array` / regex
  cleanup and the `opencode_client` model-override dance. The per-item
  `FindingCreate` validation + coercion is unchanged, so the partial-success
  `(valid, errors)` contract is preserved.
- `ingest_worker.py`: inject `env_resolver` / `model_resolver` (mirroring the
  executor's pattern); resolve the provider env per job and fall back to the
  canonical active model when a job didn't pin one.
- `main.py`: wire the worker with the app-level AI resolvers.

Tests:
- rewrite `test_normalizer.py` against a `FunctionModel` (success, partial
  failure, source_type injection, null-status default, list-wrapped
  raw_payload coercion, LLM-error → error string, model-not-configured).
- update the `_process_job` call sites + mock signatures for the new
  resolver params; pass `env`/`model` in the real-LLM eval tests and pick a
  capable eval model (a nano-tier default under-extracts on batches).

The singleton OpenCode process stays (repo-action agents still use the pool;
#3c migrates them, #3d deletes the substrate). Full unit suite: 1552 passed;
real-LLM normalizer evals pass with a capable model.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(agents): migrate repo-action agents to Pydantic AI (#3c)

IMPL-0022 PR #3c — the last per-workspace-pool consumer. The two posture
generators (security_md_generator, dependabot_config_generator, ADR-0024)
clone → write → commit → push → open a draft PR; they now run in-process
through Pydantic AI, reusing the executor's bash/edit/read/gh tools,
instead of a per-workspace OpenCode process driven by a Jinja template.

- add cliff/agents/runtime/repo_actions.py — RepoActionOutput +
  build_repo_action_agent(model, kind). Both system prompts are adapted
  for the PA tool API: self-contained `git -C repo` commands (bash does
  not persist cwd), the `edit` tool for file writes, and `output_type`
  in place of the JSON-code-block contract.
- WorkspaceDeps.auto_approve + gate_tool_call honour it: repo-action runs
  pre-approve the `ask` tier (no HITL surface for a one-shot background
  run); the `deny` tier still hard-denies catastrophic commands.
- rewrite repo_workspace_runner.RepoAgentRunner: pool.get_or_start() →
  agent.run(); drop the SSE collect/stall machinery, template render, and
  output_parser usage; keep the B16 PR-URL verification.
- rewire the _engine_dep spawner: pool → app-level AI env/model resolvers.

Also fixes a latent bug in the merged PR #2 executor: the `bash` tool
passed `env=env_vars`, which REPLACED the process environment (env_vars
carries only GH_TOKEN/CLIFF_REPO_URL) and would strip PATH/HOME so git/gh
couldn't run. Now merges over os.environ. The executor's live clone→PR
smoke was deferred, so this had never surfaced.

Tests: runner (FunctionModel + the B16 verification branches), spawner
(autouse no-op for the now-always-launched background run), and
auto_approve gating. The deterministic suite stays green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(#3d): remove the vestigial workspace-scoped chat/sessions

First slice of the substrate removal. The workspace-scoped chat/sessions
routes were the last per-workspace-pool consumer, but they're dead weight
post-migration: the PA pipeline runs agents in-process (it never used the
OpenCode session), the frontend client defined the calls but no component
invoked them, and `cliffsec fix` created a session as a leftover no-op.

- delete the 4 routes from api/routes/workspaces.py (sessions, chat/send,
  chat/stream, chat/permission) + the pool-status debug route + _get_pool;
  drop the pool-stop from delete_workspace
- drop the no-op `POST /sessions` step from `cliffsec fix`
- remove createWorkspaceSession / sendWorkspaceMessage / streamWorkspaceEvents
  / respondToChatPermission from the frontend client; tidy the stale
  IssueSidePanel comment

Nothing else drives the pool now — the singleton + pool come out next,
once the remaining singleton consumers (health, settings, ai/service,
lifespan) are rewired.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(#3d): rewire /health off the OpenCode singleton

Move HealthStatus into cliff/models (it lived in engine/models, deleted
next) and drop the live OpenCode probe. The substrate runs in-process via
Pydantic AI, so the response shape is preserved for the frontend health
card + cliffsec status: `opencode` is always "ok" and `opencode_version`
now carries the PA version string ("pydantic-ai <ver>"). Model comes from
the canonical ai_model_cache; ai_provider_ready is unchanged.

- cliff/models: add HealthStatus (kept-shape, repurposed fields)
- api/routes/health.py: drop opencode_process/opencode_client; report PA
- tests: retarget test_models + test_routes_health; reduce the now-unused
  mock_opencode_process fixture to a no-op

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(repo-actions): gh has no -C flag — cd into the clone for `gh pr create`

Code-review catch: the security.md + dependabot prompts told the agent to
run `gh -C repo pr create`, but unlike git, the GitHub CLI has no directory
flag, so the PR-create step would fail with "unknown flag: -C" and every
repo-action run would end status=failed. Route the PR-create through bash
with `cd repo && gh pr create ...` (the bash tool runs at the workspace
root; gh reads the repo from the clone's remote and the token from
$GH_TOKEN).

Also regenerates the OpenAPI snapshot for the workspace-chat/sessions +
pool-status routes removed earlier in this PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: address PR #253 review — status coercion, no-provider job, clone auth

Three confirmed review findings:

- normalizer: force `status="new"` on every item instead of only filling
  null. A stray model value (e.g. "open") no longer drops an otherwise-valid
  finding into `errors` — normalized findings are always brand-new.
- ingest_worker: short-circuit a job to terminal `failed` when no AI provider
  is configured (empty env / null model), instead of looping
  fail → pending → re-poll every ~1s forever (a new dependency the PA
  migration introduced; build_model can't run without a provider).
- repo-action prompts: restore the conditional clone URL — embed the token
  only when $GH_TOKEN is set, else clone the plain URL (matches the old Jinja
  behaviour; a token-less private clone then fails cleanly).

Tests: stray-status coercion; no-provider → failed (not retry).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(cli): drop the POST /sessions mock from `cliffsec fix` tests

The fix command no longer pre-creates a workspace session (the PA pipeline
runs agents in-process), so the three `fix` tests registered a
`POST /workspaces/ws-1/sessions` httpx mock that's never requested —
pytest_httpx fails the run on an unused mock. Remove the stale registrations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(#3d): delete the OpenCode substrate — Pydantic AI is the only agent runtime (#254)

* refactor(settings): derive model + providers from canonical AI state

Drop config_manager / opencode_client from the settings routes — the last
contract-bearing OpenCode dependency outside the engine package:

- GET/PUT /settings/model now route only through AIIntegrationService
  (canonical app_setting(model)); no opencode.json fallback. Empty
  ModelConfig when no provider is connected; PUT 503s without a vault and
  400s with no active provider / prefix mismatch.
- GET /settings/providers is rebuilt from cliff.ai.catalog (static
  per-provider picker rows) instead of OpenCode's models.dev dump. Wire
  shape {id,name,env,models} is preserved; models keyed by the bare id so
  the UI/CLI rebuild f"{provider}/{model_id}" unchanged.
- POST /settings/providers/test now runs a bounded "Say OK" through
  Pydantic AI (build_model + Agent) and classifies the outcome.
- Delete the vestigial config_manager-only routes /settings/providers/
  configured and /settings/api-keys/* (no live UI consumer) plus their
  dead frontend wrappers/hooks/types and the MSW api-keys handler.

CLI (cliffsec model get/set/list) is unaffected — same wire shapes.

Part of PR #3d (OpenCode substrate removal), stacked on #253.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(ai): drop the OpenCode auth.json dual-path

ADR-0047: the PA model factory reads the provider key straight from the
resolved env (resolve_env_for_workspace) — there is no auth.json to keep
in sync. Remove sync_to_opencode, _sync_opencode_auth, _clear_opencode_auth,
_safe_write_opencode_config, the _OPENCODE_AUTH_PROVIDERS set, and all their
call sites in save/disconnect/set_model. Canonical vault + app_setting(model)
state is untouched; _fire_key_change stays as a generic env-refresh hook.

Drop the now-obsolete auth.json-push tests and the dead opencode_client
stub fixtures.

Part of PR #3d, stacked on #253.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(main): drop the singleton OpenCode lifespan + dead process pool

ADR-0047: agents run in-process via Pydantic AI, so nothing spawns or
talks to an OpenCode process anymore. Remove from the lifespan:

- the singleton opencode_process start/stop + set_extra_env seeding
- the auth.json sync_to_opencode reconcile + config_manager
  reconcile_model / restore_keys_to_engine startup calls
- the WorkspaceProcessPool (constructed but never read after #3c — the
  executor never called it), its app.state handle, the idle-cleanup loop,
  and pool.stop_all on shutdown
- the singleton-restart half of the ai_on_key_change hook (it now only
  refreshes the warm env/model cache)
- opencode_client.close() / opencode_process.stop() teardown

AgentExecutor drops its unused ``pool`` parameter. Test construction
sites updated to the new signature.

Part of PR #3d, stacked on #253.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(workspace): stop scaffolding opencode.json + .j2 agent templates

ADR-0047: PA agents run in-process from prompts defined in
cliff.agents.runtime — they never read a workspace's opencode.json or
rendered .opencode/agents/*.md. Stop writing both:

- WorkspaceDirManager.create() drops the .opencode/agents tree, the
  opencode.json write, and its mcp_servers/model params. The dir still
  carries the finding context (finding.json, finding.md, CONTEXT.md,
  context sections) the agents read.
- create_repo_workspace() becomes a thin clone-dir allocator (history/ +
  REPO_ACTION.md); no rendered prompt, no opencode.json. The repo-action
  agent carries its own prompt + permission policy.
- WorkspaceContextBuilder drops the AgentTemplateEngine dependency and the
  per-create / per-update write_agents render passes; it still writes the
  workspace-integrations.json manifest (read by check_config_freshness).
- Delete _build_opencode_config and the WorkspaceDir opencode_json /
  opencode_dir / agents_dir properties.
- main.py lifespan + _engine_dep spawner drop the template engine / model
  plumbing.

Tests updated to assert finding context (not templates); the obsolete
opencode.json/template assertions and the OpenCode pool-wiring test
(test_ai_phase_f_wiring) are removed.

Part of PR #3d, stacked on #253.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(version): report the in-process substrate version, not OpenCode

Move VersionInfo out of the doomed cliff.engine.models into cliff.models
alongside HealthStatus, and add a shared substrate_version() helper. The
/version handshake's compat ``opencode`` field and /health's
``opencode_version`` both now carry "pydantic-ai <ver>" (ADR-0047) instead
of the pinned OpenCode binary version. /health stops falling back to
settings.opencode_model (opencode.json is gone) — the model is the
canonical ai_model_cache or empty.

The config.py opencode_* settings removal rides with the engine deletion
(they're read by engine/ at runtime until it's deleted).

Part of PR #3d, stacked on #253.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: delete the OpenCode substrate

Every OpenCode caller is gone (#3b/#3c + steps 1-5), so remove the engine
itself and everything that fed it:

Backend
- delete backend/cliff/engine/ (client, process, pool, config_manager,
  models) and backend/cliff/agents/template_engine.py + templates/*.j2
- config.py: drop the opencode_* connection/model/version settings and
  properties; repo-root sentinel moves from .opencode-version to VERSION
- agents/__init__.py stops re-exporting the template engine
- delete the engine/pool/template unit tests + the OpenCode e2e suite;
  strip the opencode fixtures from the conftests; retarget test_config /
  test_docker / the openrouter route test; regenerate the OpenAPI snapshot

Packaging / CLI
- Dockerfile: drop the OpenCode binary install step, the opencode.json /
  .opencode COPYs, and the CLIFF_OPENCODE_* env
- build-tarball / install-local / install smoke / install-smoke.yml stop
  bundling + installing + asserting the opencode binary
- CLI updater stops re-running install-opencode.sh; daemon doctor drops the
  opencode binary check (it would report "not found" post-migration)

Root
- delete .opencode-version, opencode.json, .opencode/, scripts/install-opencode.sh

Follow-up (noted, not blocking): the CLI's process_sweep still classifies a
``$CLIFF_HOME/bin/opencode`` process kind and cli.py keeps the (now always
"ok") opencode health field — dead but harmless; a separate cleanup PR.

Backend 1432 pass, CLI 97 pass, frontend tsc clean, ruff clean.

Part of PR #3d, stacked on #253.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: update CLAUDE.md for the Pydantic AI substrate

Architecture table, repo layout, "How It Runs", the workspace-runtime
layers, the AI-provider section, and the testing guidance now describe the
in-process Pydantic AI substrate (ADR-0047) instead of the OpenCode
subprocess / process pool / auth.json / drift model.

Part of PR #3d, stacked on #253.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: scrub stale OpenCode comments from the live code

Comment/docstring-only. The PA code was already correct; the prose still
described OpenCode subprocesses, an auth.json/singleton restart, a process
pool, and opencode.json MCP config that no longer exist. Reword the
load-bearing-wrong comments to describe the in-process Pydantic AI
behaviour (executor dispatch, errors, repo runner, tools, provider/catalog,
ai/service + models, gateway), and drop already-happened "deleted in
PR2.E" / "PR #3 finalizes" future-tense references.

Kept: accurate "used to run on OpenCode / pre-migration" provenance that
explains why code is shaped the way it is, the intentional ``opencode``
HealthStatus/VersionInfo wire-compat field, and the legacy_migration notes
about legacy opencode-keyed app_settings.

Part of PR #3d, stacked on #253.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: address PR #254 review comments

- settings.py: GET /settings/providers gets response_model=list[ProviderInfo]
  and returns typed instances — restores FastAPI validation + OpenAPI schema
  for a user-facing endpoint (CodeRabbit).
- tests/agents/conftest.py: skip-no-key now applies ONLY to the two live-LLM
  files (test_normalizer_agent, test_plain_description_eval); the FunctionModel/
  TestModel runtime tests run in keyless CI so a regression isn't masked
  (CodeRabbit). Verified: keyless run → FunctionModel tests run, live skip.
- test_workspace_repo_url_overrides_global.py: save/restore
  app.state.context_builder so the fixture can't leak a tmp_path-rooted
  builder into later tests (CodeRabbit).
- test_workspace_repo_creation.py: also assert history/agent-runs.jsonl exists
  (CodeRabbit).
- build-tarball.sh: drop the stale "opencode binaries" text from the generated
  README-LOCAL-INSTALL.md (CodeRabbit).
- test_ai_catalog.py: docstring no longer references "spawning OpenCode".

Part of PR #3d, stacked on #253.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(cli): finish de-OpenCode'ing the CLI process management

The agent substrate runs in-process via Pydantic AI (ADR-0047), so the CLI
no longer manages any child agent process or its ports:

- process_sweep.py: drop the ``opencode`` process kind, the
  OPENCODE_SINGLETON_PORT (4096) + WORKSPACE_PORT_RANGE (4100-4199)
  constants, and the bin/opencode classification arm. find_cliff_processes()
  is uvicorn-only and no longer needs cliff_home.
- daemon.py: _cliff_ports() + the doctor port loop only consider the app
  port now; stop/uninstall stop scanning the dead 4096/4100-4199 range;
  layout docstring drops .opencode-version + bin/opencode.
- cli.py: drop the now-unreachable ``opencode_engine_unavailable`` status
  blocker (/health.opencode is hard-wired "ok").
- updater.py docstring no longer claims install-opencode.sh.

Tests updated: test_process_sweep (uvicorn-only), test_daemon (app-port
sweep, opencode→uvicorn fixtures), test_cli (unconfigured blockers). CLI
suite 94 passed, ruff clean. Zero OpenCode references remain in cli/cliff_cli.

Part of PR #3d, stacked on #253.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(cli): assert the opencode doctor check is absent

CodeRabbit: lock the migration contract — the doctor payload must no longer
emit an ``opencode`` check (the binary version check was removed with the
substrate). Adds the explicit negative assertions alongside the subset check.

Part of PR #3d, stacked on #253.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: regenerate OpenAPI snapshot for typed /settings/providers

Adding response_model=list[ProviderInfo] to GET /settings/providers (PR
review fix) changes the schema's response component. Re-snapshot to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): doctor probes the configured app port, not the default

Baz: the doctor port check hard-coded DEFAULT_PORT, so a deployment with a
non-default CLIFF_APP_PORT would miss a real port-in-use failure (and report
8000 free while 9123 is wedged). Iterate over _cliff_ports() (the configured
app port) instead. Adds a regression test.

Part of PR #3d, stacked on #253.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: scrub remaining OpenCode references from README, license + docs

After the e2e verification passed, clean the user-facing and operational
references the substrate removal left behind:

- README: installer no longer fetches an OpenCode binary; bundled
  subprocesses are now just Trivy + Semgrep (PA is a Python dep).
- NOTICE + THIRD-PARTY-LICENSES.md: drop the OpenCode (MIT) bundled-binary
  entry — we no longer download or redistribute it.
- .dockerignore / .gitignore / CONTRIBUTING.md / scripts/dev.sh /
  scripts/install-local.sh: drop the .opencode-version + install-opencode.sh
  references.
- docker/Dockerfile + entrypoint.sh: drop the "OpenCode Go binary" stage
  comment and the CLIFF_OPENCODE_PORT/BIN runtime banner lines.
- KNOWN_ISSUES.md: remove the 9 now-resolved OpenCode-era issues (SSE/chat
  sessions, opencode.json drift, keys-lost-on-restart, 75+-provider catalog)
  and reword the orphan-PR issue's failure modes.
- plugins/cliff-security knowledge: install/doctor no longer mention an
  opencode binary or the retired 4096/4100-4102 port checks.
- frontend: PostureCard no longer shows "spinning up OpenCode…"; regenerate
  api/types.ts from the current OpenAPI snapshot (drops the deleted
  session/chat endpoints); reword the AIProviderStatus / ModelPicker
  comments + a test fixture.

Left intact: CHANGELOG.md + ROADMAP.md completed-phase milestones (accurate
history — Phase 1 genuinely was an OpenCode spike), the CLAUDE.md historical
-names note, and the intentional ``opencode``/``opencode_version`` HealthStatus
wire-compat fields.

Verified: frontend tsc + lint + build clean. (The pre-existing frontend
vitest failures live on #253 too and are uncaught by CI, which runs
lint+build only — out of scope for this PR.)

Part of PR #3d, stacked on #253.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(pa): address pre-merge review findings on #253

Full code review of the OpenCode→Pydantic AI migration before merge to
main, plus the open Baz/CodeRabbit threads. Real bugs + targeted cleanups.

Correctness:
- permissions: narrow `_auto_approved` so repo-action runs pre-approve
  ONLY the gated-bash ask bucket (rm, git reset, …). It no longer swallows
  the classifier's safe-default ask buckets (external_directory, edit-escape,
  mcp/unknown tools, empty bash) — those stay approval-gated so a confused
  repo-action fails closed instead of silently executing review-routed calls
  (CodeRabbit major). New lock-down tests cover the four buckets.
- ingest_worker: reconcile a job's pinned model with the active provider's
  env (cross-provider pins now fall back to the active model instead of
  failing auth every chunk), validate the model is `<provider>/<model>`
  before processing (a bare override no longer loops fail→pending), and
  stop counting error-only chunks as completed (Baz high ×2).
- executor: resume with a missing message-history now fails inside the try
  (marker already cleared) instead of raising before it — the run is marked
  failed rather than wedged at running/permission_pending forever.
- provider: strip a trailing `/v1` from OLLAMA_BASE_URL before appending it,
  avoiding a doubled `/v1/v1` → 404 when an operator includes it.
- normalizer: cap the model's output array at the input count so a
  hallucinated overflow can't inject more findings than were submitted.

Cleanup:
- Delete the dead duplicate bash classifier in executor.py (the live gate is
  permissions.classify_tool_request); its lock-down tests were asserting
  against the dead copy — drop them (coverage lives in test_permissions.py).
- Delete orphaned catalog.provider_env_var_names() (subprocess-env scrubbing;
  no subprocess remains) + its test.
- Extract the token-aware clone snippet shared by both repo-action prompts
  into `_clone_block(branch)` so the auth logic can't drift.
- Hoist the duplicated eval env/model setup into tests/agents/eval_utils.py.
- Fix bash.py docstring (deny raises ModelRetry, not ValueError) and remove
  stale OpenCode references in test docstrings.
- Update the cliff-security onboarding knowledge doc off the removed
  /api/settings/api-keys* routes onto /api/integrations/ai/byok + /status.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(pa): second review pass — gateway env precedence + ProviderInfo contract

From CodeRabbit's full re-scan of the PR:

- gateway: use explicit-key precedence for the MCP env block. An empty
  `environment: {}` was falsy under `or`, so it silently fell back to the
  legacy `env` (and `_find_unresolved_placeholders` repeated the mistake).
  An explicit (even empty) `environment` now wins in both spots.
- models: make `env` and `models` required on `ProviderInfo` —
  `GET /api/settings/providers` always emits both and the UI/CLI consume
  them, so generated clients must treat them as present, not optional.
  Regenerated the OpenAPI snapshot + frontend types.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant