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
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
des Hosts wird nicht eingebunden. Abgebrochene Claims werden nach standardmaessig 90
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
cp .env.example .env
# 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 run --rm automation check
docker compose run --rm automation once

View File

@@ -23,6 +23,13 @@ services:
POLL_SECONDS: ${POLL_SECONDS:-60}
STALE_CLAIM_MINUTES: ${STALE_CLAIM_MINUTES:-90}
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:
- host.docker.internal:host-gateway
volumes:

View File

@@ -33,6 +33,13 @@ class Settings:
agent_command: str
agent_model: 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
max_changed_files: int
max_changed_lines: int
@@ -71,6 +78,13 @@ class Settings:
"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")),
max_changed_files=int(os.getenv("MAX_CHANGED_FILES", "12")),
max_changed_lines=int(os.getenv("MAX_CHANGED_LINES", "800")),
@@ -87,3 +101,5 @@ class Settings:
raise ValueError(f"{self.mode} requires GITEA_TOKEN")
if not self.database_password:
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 shutil
import subprocess
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import urlparse
@@ -46,8 +47,9 @@ class GitWorkspace:
command: str,
check: bool = True,
username: str | None = None,
extra_environment: Mapping[str, str] | None = None,
) -> subprocess.CompletedProcess[str]:
environment = None
environment = dict(os.environ)
drop_privileges = None
if username:
account = pwd.getpwnam(username)
@@ -61,6 +63,8 @@ class GitWorkspace:
os.setgid(account.pw_gid)
os.setuid(account.pw_uid)
environment.update(extra_environment or {})
return subprocess.run(
shlex.split(command),
cwd=self.path,

View File

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

View File

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

View File

@@ -99,8 +99,35 @@ class AutomationWorker:
def _run_tests(self, workspace: GitWorkspace) -> list[dict]:
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:
result = workspace.run(command, check=False, username="coder")
result = workspace.run(
command,
check=False,
username="coder",
extra_environment=test_environment,
)
results.append({
"command": command,
"returncode": result.returncode,

View File

@@ -13,3 +13,26 @@ def test_aider_command_cannot_mutate_git_configuration_or_send_analytics() -> No
assert "--no-auto-commits" in command
assert command.count("/dev/null") == 2
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_model": "mistral/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,
"max_changed_files": 12,
"max_changed_lines": 800,

View File

@@ -19,8 +19,10 @@ def test_task_contains_constraints_and_redacted_report() -> None:
source="user_feedback",
category="incorrect_answer",
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",
response=None,
response="The wrong product count was returned.",
sql_query="SELECT * FROM company",
error_message=None,
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 "Do not commit or push" 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:

View File

@@ -13,6 +13,8 @@ def test_dry_run_generates_task_without_agent_or_git() -> None:
source="automatic",
category="sql_execution",
user_feedback=None,
feedback_type=None,
expected_behavior=None,
prompt="List locations",
response=None,
sql_query=None,
@@ -35,3 +37,20 @@ def test_stop_signal_interrupts_worker_wait() -> None:
worker._stop(15, None)
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"