Compare commits

...

2 Commits

Author SHA1 Message Date
2ee2887eb9 Include structured feedback in repair tasks
Some checks failed
CI / test (push) Has been cancelled
2026-08-27 18:18:04 +02:00
efce42b967 Isolate database credentials for agent tests 2026-08-27 18:17:39 +02:00
11 changed files with 131 additions and 4 deletions

View File

@@ -39,6 +39,11 @@ als privilegierter Containerprozess, startet Aider aber als separaten Benutzer `
Dieser Unterprozess erhaelt nur Modell-Credentials, nicht die Failure-DB- oder Dieser Unterprozess erhaelt nur Modell-Credentials, nicht die Failure-DB- oder
Gitea-Zugangsdaten. Gitea-Zugangsdaten.
Die nachgelagerten Testprozesse erhalten separat konfigurierte `TEST_DB_*`-Zugangsdaten.
Dieser Benutzer darf ausschliesslich die isolierten Testschemas bearbeiten. Fuer Laravel
werden daraus `DB_*`, `ERP_DB_*` und `COCKPIT_CONFIG_DATABASE` gesetzt; Aider selbst sieht
diese Werte nie. Produktive ERP- oder Kundenzugangsdaten duerfen hier nicht verwendet werden.
Laravel-Tests laufen mit PHP und Composer direkt im isolierten Worker. Der Docker-Socket Laravel-Tests laufen mit PHP und Composer direkt im isolierten Worker. Der Docker-Socket
des Hosts wird nicht eingebunden. Abgebrochene Claims werden nach standardmaessig 90 des Hosts wird nicht eingebunden. Abgebrochene Claims werden nach standardmaessig 90
Minuten wieder freigegeben; `STALE_CLAIM_MINUTES` passt dieses Intervall an. Minuten wieder freigegeben; `STALE_CLAIM_MINUTES` passt dieses Intervall an.
@@ -48,6 +53,7 @@ Minuten wieder freigegeben; `STALE_CLAIM_MINUTES` passt dieses Intervall an.
```bash ```bash
cp .env.example .env cp .env.example .env
# FAILURE_DB_PASSWORD und bei Bedarf MISTRAL_API_KEY eintragen # FAILURE_DB_PASSWORD und bei Bedarf MISTRAL_API_KEY eintragen
# TEST_DB_PASSWORD fuer den Benutzer der isolierten Testschemas eintragen
docker compose build docker compose build
docker compose run --rm automation check docker compose run --rm automation check
docker compose run --rm automation once docker compose run --rm automation once

View File

@@ -23,6 +23,13 @@ services:
POLL_SECONDS: ${POLL_SECONDS:-60} POLL_SECONDS: ${POLL_SECONDS:-60}
STALE_CLAIM_MINUTES: ${STALE_CLAIM_MINUTES:-90} STALE_CLAIM_MINUTES: ${STALE_CLAIM_MINUTES:-90}
TEST_COMMANDS: ${TEST_COMMANDS:-composer install --no-interaction --prefer-dist;;php artisan test} TEST_COMMANDS: ${TEST_COMMANDS:-composer install --no-interaction --prefer-dist;;php artisan test}
TEST_DB_HOST: ${TEST_DB_HOST:-host.docker.internal}
TEST_DB_PORT: ${TEST_DB_PORT:-33060}
TEST_DB_NAME: ${TEST_DB_NAME:-laravel_ai_chat_agent_test}
TEST_DB_USER: ${TEST_DB_USER:-lai_test_runner}
TEST_DB_PASSWORD: ${TEST_DB_PASSWORD:-agent_test_secret}
TEST_CUSTOMER_DB: ${TEST_CUSTOMER_DB:-customer_agent_test}
TEST_CONFIG_DB: ${TEST_CONFIG_DB:-cockpit_agent_test}
extra_hosts: extra_hosts:
- host.docker.internal:host-gateway - host.docker.internal:host-gateway
volumes: volumes:

View File

@@ -33,6 +33,13 @@ class Settings:
agent_command: str agent_command: str
agent_model: str agent_model: str
test_commands: tuple[str, ...] test_commands: tuple[str, ...]
test_database_host: str
test_database_port: int
test_database_name: str
test_database_user: str
test_database_password: str
test_customer_database: str
test_config_database: str
poll_seconds: int poll_seconds: int
max_changed_files: int max_changed_files: int
max_changed_lines: int max_changed_lines: int
@@ -71,6 +78,13 @@ class Settings:
"composer install --no-interaction --prefer-dist;;php artisan test", "composer install --no-interaction --prefer-dist;;php artisan test",
) )
), ),
test_database_host=os.getenv("TEST_DB_HOST", "host.docker.internal"),
test_database_port=int(os.getenv("TEST_DB_PORT", "33060")),
test_database_name=os.getenv("TEST_DB_NAME", "laravel_ai_chat_agent_test"),
test_database_user=os.getenv("TEST_DB_USER", "lai_test_runner"),
test_database_password=os.getenv("TEST_DB_PASSWORD", ""),
test_customer_database=os.getenv("TEST_CUSTOMER_DB", "customer_agent_test"),
test_config_database=os.getenv("TEST_CONFIG_DB", "cockpit_agent_test"),
poll_seconds=int(os.getenv("POLL_SECONDS", "60")), poll_seconds=int(os.getenv("POLL_SECONDS", "60")),
max_changed_files=int(os.getenv("MAX_CHANGED_FILES", "12")), max_changed_files=int(os.getenv("MAX_CHANGED_FILES", "12")),
max_changed_lines=int(os.getenv("MAX_CHANGED_LINES", "800")), max_changed_lines=int(os.getenv("MAX_CHANGED_LINES", "800")),
@@ -87,3 +101,5 @@ class Settings:
raise ValueError(f"{self.mode} requires GITEA_TOKEN") raise ValueError(f"{self.mode} requires GITEA_TOKEN")
if not self.database_password: if not self.database_password:
raise ValueError("FAILURE_DB_PASSWORD is required") raise ValueError("FAILURE_DB_PASSWORD is required")
if not self.test_database_password:
raise ValueError("TEST_DB_PASSWORD is required")

View File

@@ -5,6 +5,7 @@ import pwd
import shlex import shlex
import shutil import shutil
import subprocess import subprocess
from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -46,8 +47,9 @@ class GitWorkspace:
command: str, command: str,
check: bool = True, check: bool = True,
username: str | None = None, username: str | None = None,
extra_environment: Mapping[str, str] | None = None,
) -> subprocess.CompletedProcess[str]: ) -> subprocess.CompletedProcess[str]:
environment = None environment = dict(os.environ)
drop_privileges = None drop_privileges = None
if username: if username:
account = pwd.getpwnam(username) account = pwd.getpwnam(username)
@@ -61,6 +63,8 @@ class GitWorkspace:
os.setgid(account.pw_gid) os.setgid(account.pw_gid)
os.setuid(account.pw_uid) os.setuid(account.pw_uid)
environment.update(extra_environment or {})
return subprocess.run( return subprocess.run(
shlex.split(command), shlex.split(command),
cwd=self.path, cwd=self.path,

View File

@@ -12,6 +12,8 @@ class FailureReport:
source: str source: str
category: str category: str
user_feedback: str | None user_feedback: str | None
feedback_type: str | None
expected_behavior: str | None
prompt: str | None prompt: str | None
response: str | None response: str | None
sql_query: str | None sql_query: str | None
@@ -29,6 +31,8 @@ class FailureReport:
source=row["source"], source=row["source"],
category=row["category"], category=row["category"],
user_feedback=row.get("user_feedback"), user_feedback=row.get("user_feedback"),
feedback_type=row.get("feedback_type"),
expected_behavior=row.get("expected_behavior"),
prompt=row.get("prompt"), prompt=row.get("prompt"),
response=row.get("response"), response=row.get("response"),
sql_query=row.get("sql_query"), sql_query=row.get("sql_query"),

View File

@@ -29,6 +29,8 @@ def redact(value: str | None) -> str:
def build_task(report: FailureReport) -> str: def build_task(report: FailureReport) -> str:
prompt = redact(report.prompt) prompt = redact(report.prompt)
feedback = redact(report.user_feedback) feedback = redact(report.user_feedback)
expected_behavior = redact(report.expected_behavior)
response = redact(report.response)
error = redact(report.error_message) error = redact(report.error_message)
sql = redact(report.sql_query) sql = redact(report.sql_query)
@@ -44,6 +46,7 @@ Rules:
- Do not commit or push; the orchestrator owns Git publication. - Do not commit or push; the orchestrator owns Git publication.
Failure category: {report.category} Failure category: {report.category}
Feedback type: {report.feedback_type or 'not specified'}
Source: {report.source} Source: {report.source}
Model: {redact(str(report.context.get('model', 'unknown')))} Model: {redact(str(report.context.get('model', 'unknown')))}
Database type: {redact(str(report.context.get('database_type', 'unknown')))} Database type: {redact(str(report.context.get('database_type', 'unknown')))}
@@ -54,6 +57,12 @@ User request:
User feedback: User feedback:
{feedback or '[not available]'} {feedback or '[not available]'}
Expected behavior:
{expected_behavior or '[not available]'}
Observed answer:
{response or '[not available]'}
Observed error: Observed error:
{error or '[not available]'} {error or '[not available]'}

View File

@@ -99,8 +99,35 @@ class AutomationWorker:
def _run_tests(self, workspace: GitWorkspace) -> list[dict]: def _run_tests(self, workspace: GitWorkspace) -> list[dict]:
results = [] results = []
test_environment = {
"AGENT_TEST_DB_ENABLED": "true",
"TEST_DB_HOST": self.settings.test_database_host,
"TEST_DB_PORT": str(self.settings.test_database_port),
"TEST_DB_NAME": self.settings.test_database_name,
"TEST_DB_USER": self.settings.test_database_user,
"TEST_DB_PASSWORD": self.settings.test_database_password,
"TEST_CUSTOMER_DB": self.settings.test_customer_database,
"TEST_CONFIG_DB": self.settings.test_config_database,
"DB_CONNECTION": "mysql",
"DB_HOST": self.settings.test_database_host,
"DB_PORT": str(self.settings.test_database_port),
"DB_DATABASE": self.settings.test_database_name,
"DB_USERNAME": self.settings.test_database_user,
"DB_PASSWORD": self.settings.test_database_password,
"ERP_DB_HOST": self.settings.test_database_host,
"ERP_DB_PORT": str(self.settings.test_database_port),
"ERP_DATABASE": self.settings.test_customer_database,
"ERP_DB_USERNAME": self.settings.test_database_user,
"ERP_DB_PASSWORD": self.settings.test_database_password,
"COCKPIT_CONFIG_DATABASE": self.settings.test_config_database,
}
for command in self.settings.test_commands: for command in self.settings.test_commands:
result = workspace.run(command, check=False, username="coder") result = workspace.run(
command,
check=False,
username="coder",
extra_environment=test_environment,
)
results.append({ results.append({
"command": command, "command": command,
"returncode": result.returncode, "returncode": result.returncode,

View File

@@ -12,4 +12,27 @@ def test_aider_command_cannot_mutate_git_configuration_or_send_analytics() -> No
assert "--no-analytics" in command assert "--no-analytics" in command
assert "--no-auto-commits" in command assert "--no-auto-commits" in command
assert command.count("/dev/null") == 2 assert command.count("/dev/null") == 2
assert command[-4:] == ["--edit-format", "diff", "--timeout", "60"] assert command[-4:] == ["--edit-format", "diff", "--timeout", "60"]
def test_aider_environment_does_not_include_database_credentials(tmp_path, monkeypatch) -> None:
config = settings(workspace_root=tmp_path)
workspace = tmp_path / "repo"
(workspace / ".git").mkdir(parents=True)
monkeypatch.setenv("TEST_DB_PASSWORD", "must-not-reach-aider")
captured = {}
def run(command, **kwargs):
captured.update(kwargs["env"])
return type("Result", (), {"returncode": 0, "stdout": "ok", "stderr": ""})()
monkeypatch.setattr("lai_automation.agent.subprocess.run", run)
monkeypatch.setattr("lai_automation.agent.os.chown", lambda *args, **kwargs: None)
monkeypatch.setattr("lai_automation.agent.pwd.getpwnam", lambda _: type(
"Account", (), {"pw_dir": str(tmp_path), "pw_uid": 10001, "pw_gid": 10001}
)())
AiderAgent(config).run(workspace, "fix it")
assert "TEST_DB_PASSWORD" not in captured
assert "DB_PASSWORD" not in captured

View File

@@ -25,6 +25,13 @@ def settings(**overrides) -> Settings:
"agent_command": "aider", "agent_command": "aider",
"agent_model": "mistral/test", "agent_model": "mistral/test",
"test_commands": ("php artisan test",), "test_commands": ("php artisan test",),
"test_database_host": "test-db",
"test_database_port": 3306,
"test_database_name": "lai_agent_test",
"test_database_user": "test_runner",
"test_database_password": "test-secret",
"test_customer_database": "customer_agent_test",
"test_config_database": "cockpit_agent_test",
"poll_seconds": 60, "poll_seconds": 60,
"max_changed_files": 12, "max_changed_files": 12,
"max_changed_lines": 800, "max_changed_lines": 800,

View File

@@ -19,8 +19,10 @@ def test_task_contains_constraints_and_redacted_report() -> None:
source="user_feedback", source="user_feedback",
category="incorrect_answer", category="incorrect_answer",
user_feedback="password=secret-value", user_feedback="password=secret-value",
feedback_type="wrong_join",
expected_behavior="Join FK_Artikel with FK_AuftragPos and filter Auftragkennung = 3.",
prompt="List locations for admin@example.com", prompt="List locations for admin@example.com",
response=None, response="The wrong product count was returned.",
sql_query="SELECT * FROM company", sql_query="SELECT * FROM company",
error_message=None, error_message=None,
context={"model": "mistral", "database_type": "customer"}, context={"model": "mistral", "database_type": "customer"},
@@ -31,6 +33,9 @@ def test_task_contains_constraints_and_redacted_report() -> None:
assert "admin@example.com" not in task assert "admin@example.com" not in task
assert "Do not commit or push" in task assert "Do not commit or push" in task
assert "never mock or stub the behavior being tested" in task assert "never mock or stub the behavior being tested" in task
assert "Feedback type: wrong_join" in task
assert "Join FK_Artikel with FK_AuftragPos and filter Auftragkennung = 3." in task
assert "The wrong product count was returned." in task
def test_redact_removes_extended_credentials() -> None: def test_redact_removes_extended_credentials() -> None:

View File

@@ -13,6 +13,8 @@ def test_dry_run_generates_task_without_agent_or_git() -> None:
source="automatic", source="automatic",
category="sql_execution", category="sql_execution",
user_feedback=None, user_feedback=None,
feedback_type=None,
expected_behavior=None,
prompt="List locations", prompt="List locations",
response=None, response=None,
sql_query=None, sql_query=None,
@@ -35,3 +37,20 @@ def test_stop_signal_interrupts_worker_wait() -> None:
worker._stop(15, None) worker._stop(15, None)
assert worker.stop_event.is_set() assert worker.stop_event.is_set()
def test_database_credentials_are_only_passed_to_test_process() -> None:
worker = AutomationWorker(settings())
workspace = Mock()
workspace.run.return_value = Mock(returncode=0, stdout="ok", stderr="")
results = worker._run_tests(workspace)
assert results[0]["returncode"] == 0
environment = workspace.run.call_args.kwargs["extra_environment"]
assert environment["AGENT_TEST_DB_ENABLED"] == "true"
assert environment["TEST_DB_USER"] == "test_runner"
assert environment["TEST_DB_PASSWORD"] == "test-secret"
assert environment["DB_DATABASE"] == "lai_agent_test"
assert environment["ERP_DATABASE"] == "customer_agent_test"
assert environment["COCKPIT_CONFIG_DATABASE"] == "cockpit_agent_test"