fix(2.8.1): Sechs Befunde, eine Bauart - die Voreinstellung, die einsprang
Drei Agenten haben von aussen gemeldet, und fast jeder Befund hat dieselbe Form: Irgendwo sprang ein Vorgabewert ein, obwohl die richtige Antwort bekannt war - meist das Projekt, an das der Aufrufer ohnehin gebunden ist. #351 manage?action=next antwortete durchgaengig mit HTTP 500. In BugRepo::buildFilter() stand :project_scope zweimal in derselben Anweisung; mit ATTR_EMULATE_PREPARES=false bindet PDO jeden Namen genau einmal - zwei Marker, ein Wert, SQLSTATE[HY093]. Getroffen war nur "next", weil nur dort der Rueckkanal-Filter gesetzt wird. Damit gab es keine exklusive Uebernahme. Es ist der VIERTE Fall dieser Bauart, deshalb prueft scripts/pruefe_platzhalter.py das jetzt mechanisch vor jedem Ausliefern. #382 Meldungen ohne project_slug landeten im Scheinprojekt "default" - #387 ohne dc_projects-Zeile, also project_id NULL. Die Antwort sah wie ein #397 Erfolg aus, und danach sperrte dieselbe Token-Bindung den Zugriff (403 project_forbidden). Der Rueckfall aufs Heimatprojekt war an !isWorkbench gebunden; ein Werkbank-Token ohne Slug bekam gar keinen Vorgabewert. Jetzt: genannter Slug, sonst Heimatprojekt, sonst 400. Ein unbekanntes Projekt wird abgewiesen statt still angelegt. #364 Ohne project_slug lieferten list, stats und die Roadmap ALLE #176 Projekte statt des eigenen, weil die Vorgabe auf "all" stand und ein Werkbank-Token beim Lesen absichtlich nicht unter die Schranke faellt. Gemessen: 393 Items, davon 28 eigene. Ausgerechnet der erste Aufruf jeder Sitzung. Die Roadmap antwortet ausserdem schmal - 55 Felder je Eintrag waren 628.392 Zeichen und fuellten das Kontextfenster. #404 Watchdog-Monitore bekamen nie ein Projekt: upsertHeartbeat() wurde keines uebergeben, obwohl authorizeSource() die Token-Bindung liest. Befunde erbten dadurch "default" - dc_findings(project_slug=...) war fuer eine laufende, meldende Instanz leer. #131 Wo der Leseweg richtig verengte, sagte er es nicht: HTTP 200 mit dem umgeschriebenen Filter sieht aus wie eine Auskunft ueber das angefragte Projekt. Ein Agent, der vor dem Melden dc_search fragt, legt so das Duplikat an. Jetzt scoped_to und scope_note im Umschlag - nur bei tatsaechlicher Verengung. #354 dc-testkit 1.1.0 wurde zweimal mit verschiedenem Inhalt ausgeliefert (1898 und 1930 Zeilen), 1.0.0 davor ebenso. Der Absatz ueber DC_TESTKIT_VERSION stand die ganze Zeit im Skript und hat nicht getragen. Jetzt 1.1.1 plus ein Riegel in deploy.py, der abbricht, wenn sich der Inhalt aendert und die Nummer nicht. Warum die Testsuite all das nicht sah: Ihr Master-Token ist an kein Projekt gebunden und nimmt deshalb ueberall den anderen Zweig. Es gab sogar einen Testfall fuer action=next - er lief gruen, waehrend der Aufruf in der Praxis ausnahmslos scheiterte. Die neuen Faelle benutzen ausdruecklich projektgebundene Token; testctl kann dafuer jetzt owner_type und scopes waehlen. Ausserdem: Die Agentenvorlage nennt fuer die SDK-Fassung dc_link_project statt einer abgeschriebenen Zahl - "SDK 2.5.1" stand dort als Beispiel und wurde als Wert uebernommen. Nicht enthalten: die 20 Altlasten in "default". Sie sind ueber die API nicht erreichbar; sql/wartung/2026-08-31_default-projekt-aufraeumen.sql liegt dafuer bereit (alle 20 haben einen titelgleichen Zwilling im richtigen Projekt). 156 e2e-Faelle gruen, davon 9 neue. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8274a6c0ac
commit
0e93345ab8
@@ -105,6 +105,11 @@ $author = $context['actor'];
|
||||
// muss die Landschaft sehen.
|
||||
$boundProject = ApiAuth::projectFilter($context);
|
||||
$readProject = ApiAuth::projectFilter($context, true);
|
||||
$homeProject = ApiAuth::homeProject($context);
|
||||
|
||||
// Was der Aufrufer ANGEFRAGT hat - unabhaengig davon, was er bekommt.
|
||||
// Gebraucht fuer den Hinweis auf eine Verengung, siehe verengungsHinweis().
|
||||
$angefragtesProjekt = Http::str('project_slug') ?? Http::str('project');
|
||||
|
||||
$itemId = resolveItemId();
|
||||
|
||||
@@ -120,8 +125,16 @@ switch ($action) {
|
||||
// "Http::str('project_slug') ?? $boundProject" - damit konnte ein an
|
||||
// Projekt X gebundenes Token die Zahlen jedes anderen Projekts abrufen,
|
||||
// indem es den Slug einfach mitschickte.
|
||||
$slug = $readProject ?? Http::str('project_slug');
|
||||
Http::ok(['stats' => $repo->getStats($slug)]);
|
||||
//
|
||||
// Die Vorgabe ohne Angabe ist das Heimatprojekt, nicht "alle": Ein
|
||||
// Werkbank-Token faellt beim Lesen nicht unter $readProject, bekam
|
||||
// also die Zahlen aller Projekte in einem Topf - dieselbe stille
|
||||
// Verwechslung wie bei list und roadmap.
|
||||
$slug = $readProject ?? Http::str('project_slug') ?? $homeProject;
|
||||
Http::ok(array_merge(
|
||||
['stats' => $repo->getStats($slug)],
|
||||
ApiAuth::scopeNotice($angefragtesProjekt, (string)$slug)
|
||||
));
|
||||
|
||||
case 'get':
|
||||
requireItemId($itemId);
|
||||
@@ -133,9 +146,9 @@ switch ($action) {
|
||||
Http::ok(['item' => $item]);
|
||||
|
||||
case 'list':
|
||||
$filters = collectFilters($readProject);
|
||||
$filters = collectFilters($readProject, $homeProject);
|
||||
$result = $repo->getItems($filters);
|
||||
Http::ok([
|
||||
Http::ok(array_merge([
|
||||
'count' => count($result['items']),
|
||||
'total' => $result['total'],
|
||||
'limit' => $result['limit'],
|
||||
@@ -143,7 +156,7 @@ switch ($action) {
|
||||
'has_more' => $result['has_more'],
|
||||
'filters' => $filters,
|
||||
'items' => $result['items'],
|
||||
]);
|
||||
], ApiAuth::scopeNotice($angefragtesProjekt, (string)$filters['project_slug'])));
|
||||
|
||||
// -------------------------------------------------------------- schreiben
|
||||
case 'claim':
|
||||
@@ -160,7 +173,7 @@ switch ($action) {
|
||||
Http::ok(['item' => $claimed, 'message' => sprintf('Item #%d uebernommen.', $itemId)]);
|
||||
|
||||
case 'next':
|
||||
$filters = collectFilters($boundProject);
|
||||
$filters = collectFilters($boundProject, ApiAuth::homeProject($context));
|
||||
|
||||
// Der Rueckkanal: zusaetzlich zu den eigenen Items auch fremde, die auf
|
||||
// Nachpruefung warten und bei denen dieses Projekt als betroffen
|
||||
@@ -440,12 +453,34 @@ function assertProject(
|
||||
/**
|
||||
* Sammelt Filter aus Query und Body.
|
||||
*
|
||||
* @param ?string $boundProject Zwang: aus dieser Bindung kommt das Token nicht heraus.
|
||||
* @param ?string $homeProject Vorgabe: wo der Aufrufer zuhause ist.
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
function collectFilters(?string $boundProject): array
|
||||
function collectFilters(?string $boundProject, ?string $homeProject = null): array
|
||||
{
|
||||
// WELCHES PROJEKT, WENN KEINES GENANNT IST?
|
||||
//
|
||||
// Hier stand schlicht 'all'. Das klingt harmlos und war es nicht: Ein
|
||||
// Werkbank-Token IST an ein Projekt gebunden, faellt beim Lesen aber
|
||||
// absichtlich nicht unter die Schranke - es soll die Landschaft sehen
|
||||
// duerfen (siehe $readProject weiter oben). Damit war $boundProject null,
|
||||
// die Vorgabe griff, und der Agent bekam die Items aller Projekte.
|
||||
//
|
||||
// Ausgerechnet am Sitzungsanfang: CLAUDE.md macht dc_roadmap zum ersten
|
||||
// Aufruf. Der Agent sah einen fremden Backlog - den er nach derselben
|
||||
// Regel nicht bearbeiten darf. Die Werkzeugbeschreibung sagte dazu das
|
||||
// Gegenteil ("Ohne project_slug das eigene Projekt"), was den Widerspruch
|
||||
// lange verdeckt hat.
|
||||
//
|
||||
// Jetzt gilt: ein ausdruecklich genannter Slug, sonst das Heimatprojekt,
|
||||
// und erst ohne beides "alle". Wer die Landschaft sehen will, schickt
|
||||
// weiterhin project_slug=all - nur ist das jetzt eine Entscheidung statt
|
||||
// eines Nebeneffekts.
|
||||
$angefragt = Http::str('project_slug') ?? Http::str('project');
|
||||
|
||||
$filters = [
|
||||
'project_slug' => Http::str('project_slug') ?? Http::str('project') ?? 'all',
|
||||
'project_slug' => $angefragt ?? $homeProject ?? 'all',
|
||||
'environment' => Http::str('environment') ?? Http::str('env') ?? 'all',
|
||||
'type' => Http::str('type') ?? 'all',
|
||||
'status' => Http::str('status') ?? 'all',
|
||||
@@ -458,6 +493,12 @@ function collectFilters(?string $boundProject): array
|
||||
'order' => Http::str('order') ?? 'newest',
|
||||
'limit' => Http::int('limit', 100),
|
||||
'offset' => Http::int('offset', 0),
|
||||
|
||||
// Hier bleibt der volle Datensatz die Vorgabe: Wer sucht, will die
|
||||
// Fundstelle sehen, nicht nur ihren Titel. fields=compact schneidet
|
||||
// auf die Uebersichtsfelder zurueck - dieselbe Auswahl, die die
|
||||
// Roadmap standardmaessig liefert (BugRepo::COMPACT_FIELDS).
|
||||
'fields' => Http::str('fields') === 'compact' ? 'compact' : 'full',
|
||||
];
|
||||
|
||||
if (Http::input('unclaimed_only') !== null) {
|
||||
|
||||
@@ -66,13 +66,65 @@ ApiAuth::enforceProject($context, $projectSlug, true);
|
||||
|
||||
$homeProject = ApiAuth::homeProject($context);
|
||||
|
||||
// Ein Laufzeit-Token schreibt immer in sein eigenes Projekt.
|
||||
if ($homeProject !== null && !ApiAuth::isWorkbench($context)) {
|
||||
// Wohin die Meldung geht, wenn der Melder es nicht sagt.
|
||||
//
|
||||
// Hier stand die Bedingung "!isWerkbank": ein Laufzeit-Token wurde auf sein
|
||||
// Heimatprojekt festgenagelt, ein Werkbank-Token nicht - denn das darf ja
|
||||
// ausdruecklich in fremde Projekte melden. Uebersehen wurde der Fall, um den
|
||||
// es hier geht: Ein Werkbank-Token, das GAR KEIN Projekt nennt, wollte nicht
|
||||
// "irgendwohin", sondern nach Hause. Ohne Slug fiel es durch bis zur
|
||||
// Voreinstellung in BugRepo::reportItem() - und die hiess 'default'.
|
||||
//
|
||||
// 'default' ist kein Projekt. Es steht in keiner dc_projects-Zeile, also
|
||||
// blieb project_id NULL, und die Bindung desselben Tokens schlug beim
|
||||
// naechsten Zugriff zu: action=update antwortete project_forbidden. Der
|
||||
// Server legte Items in einem Projekt ab, das derselbe Melder nicht mehr
|
||||
// anfassen durfte - und die Antwort sah wie ein Erfolg aus.
|
||||
//
|
||||
// Die Regel jetzt in einem Satz: Ein Slug im Body gilt (soweit die Bindung
|
||||
// ihn erlaubt), sonst gilt das Heimatprojekt. Ein Ziel zu raten ist an dieser
|
||||
// Stelle keine Option mehr.
|
||||
$nennenswert = $projectSlug !== null && $projectSlug !== '';
|
||||
|
||||
if ($homeProject !== null && (!$nennenswert || !ApiAuth::isWorkbench($context))) {
|
||||
$data['project_slug'] = $homeProject;
|
||||
}
|
||||
|
||||
// --- Ist das Ziel ueberhaupt ein Projekt? -----------------------------------
|
||||
$repo = new BugRepo($db);
|
||||
|
||||
$ziel = is_string($data['project_slug'] ?? null) ? trim($data['project_slug']) : null;
|
||||
|
||||
if ($ziel === null || $ziel === '') {
|
||||
Http::fail(
|
||||
400,
|
||||
'missing_project',
|
||||
'Es ist nicht erkennbar, wohin diese Meldung gehoert: weder wurde "project_slug" '
|
||||
. 'mitgeschickt, noch ist dieses Token an ein Projekt gebunden. Die Projektliste '
|
||||
. 'liefert GET /api/bugtracker/v1/projects.'
|
||||
);
|
||||
}
|
||||
|
||||
// Ein ausdruecklich genanntes Projekt muss es geben. Frueher wurde ein Tippfehler
|
||||
// klaglos angenommen und das Item mit project_id NULL abgelegt - sichtbar in
|
||||
// keiner Projektansicht, unerreichbar fuer jedes projektgebundene Token.
|
||||
//
|
||||
// Das HEIMATPROJEKT wird bewusst nicht geprueft: Steht in der Token-Bindung ein
|
||||
// Slug ohne dc_projects-Zeile, ist das ein Fehler in der Tokenverwaltung. Eine
|
||||
// Meldung deshalb abzuweisen hiesse, sie zu verlieren - und der Melder koennte
|
||||
// nichts daran aendern.
|
||||
if ($ziel !== $homeProject && !$repo->projectExists($ziel)) {
|
||||
Http::fail(
|
||||
400,
|
||||
'unknown_project',
|
||||
sprintf('Das Projekt "%s" gibt es nicht. Bekannte Slugs nennt GET /api/bugtracker/v1/projects.', $ziel),
|
||||
null,
|
||||
['requested_project' => $ziel, 'home_project' => $homeProject]
|
||||
);
|
||||
}
|
||||
|
||||
// --- Fremdmeldung? ----------------------------------------------------------
|
||||
$target = is_string($data['project_slug'] ?? null) ? trim($data['project_slug']) : null;
|
||||
$target = $ziel;
|
||||
$isForeign = $homeProject !== null
|
||||
&& $target !== null && $target !== ''
|
||||
&& $target !== $homeProject;
|
||||
@@ -123,7 +175,6 @@ if (empty($data['client_ref'])) {
|
||||
}
|
||||
}
|
||||
|
||||
$repo = new BugRepo($db);
|
||||
$result = $repo->reportItem($data);
|
||||
|
||||
// Bekannt und in einer spaeteren Fassung behoben. Diese Antwort geht jeder
|
||||
@@ -164,6 +215,9 @@ if (!empty($result['fix_insufficient'])) {
|
||||
|
||||
Http::ok([
|
||||
'item_id' => $result['id'],
|
||||
// Wohin die Meldung tatsaechlich ging. Fehlte bisher - deshalb konnte
|
||||
// eine Meldung im Nichts landen, ohne dass die Antwort es verriet.
|
||||
'project_slug' => $result['project_slug'] ?? null,
|
||||
'is_new' => $result['is_new'],
|
||||
'idempotent_hit' => $result['idempotent_hit'],
|
||||
'occurrence_count' => $result['occurrence_count'],
|
||||
|
||||
@@ -66,8 +66,19 @@ $author = $context['actor'];
|
||||
$readProject = ApiAuth::projectFilter($context, true);
|
||||
$writeProject = ApiAuth::projectFilter($context);
|
||||
|
||||
$requested = Http::str('project_slug') ?? Http::str('project') ?? 'all';
|
||||
$scope = $readProject ?? $requested;
|
||||
// Ohne Angabe gilt das Heimatprojekt - nicht "alle".
|
||||
//
|
||||
// Hier stand "?? 'all'". Ein Werkbank-Token faellt beim Lesen absichtlich
|
||||
// nicht unter $readProject (es soll die Landschaft sehen duerfen), also griff
|
||||
// die Vorgabe, und dc_roadmap lieferte die Vorhaben aller Projekte. Da
|
||||
// CLAUDE.md dc_roadmap zum ersten Aufruf jeder Sitzung macht, war das der
|
||||
// erste Eindruck: ein fremder Backlog, den derselbe Agent nach derselben
|
||||
// Regel nicht bearbeiten darf.
|
||||
//
|
||||
// project_slug=all bleibt moeglich und ist jetzt eine Ansage statt einer
|
||||
// Voreinstellung.
|
||||
$angefragt = Http::str('project_slug') ?? Http::str('project');
|
||||
$scope = $readProject ?? $angefragt ?? ApiAuth::homeProject($context) ?? 'all';
|
||||
|
||||
switch ($action) {
|
||||
case 'epics':
|
||||
@@ -133,12 +144,29 @@ switch ($action) {
|
||||
'order' => 'roadmap',
|
||||
'limit' => Http::int('limit', 200) ?: 200,
|
||||
'offset' => Http::int('offset', 0) ?: 0,
|
||||
|
||||
// Standardmaessig die schmale Sicht.
|
||||
//
|
||||
// Vorher kam der volle Datensatz mit rund fuenfzig Feldern je
|
||||
// Eintrag - fuer 57 Vorhaben etwa 137.000 Zeichen. Da CLAUDE.md
|
||||
// diesen Aufruf an den Anfang jeder Sitzung stellt, fuellte
|
||||
// ausgerechnet er das Kontextfenster, bevor die Arbeit begann.
|
||||
//
|
||||
// Eine Roadmap beantwortet "was ist dran, wer sitzt dran". Die
|
||||
// Begruendung steht ohnehin nicht hier, sondern hinter doc_anchor;
|
||||
// die Einzelheiten holt dc_get_item. fields=full liefert weiterhin
|
||||
// alles, fuer den Fall, dass jemand genau das braucht.
|
||||
'fields' => Http::str('fields') === 'full' ? 'full' : 'compact',
|
||||
];
|
||||
|
||||
$result = $repo->getItems($filters);
|
||||
$result['fields'] = $filters['fields'];
|
||||
$result['stats'] = $roadmap->stats($scope);
|
||||
|
||||
Http::ok($result);
|
||||
// Wenn ein gebundenes Token nach einem fremden Projekt fragt, bekommt
|
||||
// es die eigene Roadmap - das ist richtig, darf aber nicht wie eine
|
||||
// Auskunft ueber das angefragte Projekt aussehen (#131).
|
||||
Http::ok(array_merge($result, ApiAuth::scopeNotice($angefragt, $scope)));
|
||||
}
|
||||
|
||||
case 'set': {
|
||||
@@ -218,7 +246,7 @@ switch ($action) {
|
||||
}
|
||||
|
||||
case 'reorder': {
|
||||
$project = requireProject($writeProject ?? $requested, $context);
|
||||
$project = requireProject($writeProject ?? $angefragt, $context);
|
||||
$epic = Http::str('epic');
|
||||
$ids = Http::input('ids');
|
||||
|
||||
@@ -235,7 +263,7 @@ switch ($action) {
|
||||
}
|
||||
|
||||
case 'epic': {
|
||||
$project = requireProject($writeProject ?? $requested, $context);
|
||||
$project = requireProject($writeProject ?? $angefragt, $context);
|
||||
$epic = Http::str('epic');
|
||||
$title = Http::str('title');
|
||||
|
||||
|
||||
@@ -65,6 +65,20 @@ if ($boundProject !== null) {
|
||||
$projectSlug = $boundProject;
|
||||
}
|
||||
|
||||
// Kein Ziel, kein Ablegen. Hier stand weiter unten "?? 'default'" - dieselbe
|
||||
// Falltuer wie im Bugtracker-Eingang: 'default' ist kein Projekt, das Item
|
||||
// bekam project_id NULL und war danach in keiner Projektansicht zu finden.
|
||||
// Ein Laufzeitfehler ohne Absender ist nichts wert; er soll abgewiesen werden,
|
||||
// solange man ihn noch richtigstellen kann.
|
||||
if ($projectSlug === null || $projectSlug === '') {
|
||||
Http::fail(
|
||||
400,
|
||||
'missing_project',
|
||||
'Es ist nicht erkennbar, zu welchem Projekt dieser Fehler gehoert: weder wurde '
|
||||
. '"project_slug" mitgeschickt, noch ist dieses Token an ein Projekt gebunden.'
|
||||
);
|
||||
}
|
||||
|
||||
// --- Eingaben normalisieren ---
|
||||
$exception = trimOrNull($data['exception'] ?? $data['exception_class'] ?? $data['type'] ?? null);
|
||||
$message = trimOrNull($data['message'] ?? $data['error_message'] ?? null);
|
||||
@@ -85,7 +99,7 @@ if (!in_array($level, BugRepo::ERROR_LEVELS, true)) {
|
||||
$repo = new BugRepo($db);
|
||||
|
||||
$result = $repo->reportItem([
|
||||
'project_slug' => $projectSlug ?? 'default',
|
||||
'project_slug' => $projectSlug,
|
||||
'type' => 'bug',
|
||||
'title' => buildTitle($exception, $message),
|
||||
'error_message' => $message,
|
||||
|
||||
@@ -59,11 +59,21 @@ switch ($action) {
|
||||
Http::fail(400, 'missing_source', 'Das Feld "source" wird benoetigt.');
|
||||
}
|
||||
|
||||
authorizeSource($db, $source);
|
||||
$pingKontext = authorizeSource($db, $source);
|
||||
|
||||
$instance = Http::str('instance') ?? 'default';
|
||||
$metrics = Http::input('metrics');
|
||||
|
||||
// Das Projekt kommt aus der Token-Bindung, nicht aus dem Body - wie
|
||||
// bei report.php. Ein Monitor soll nicht behaupten koennen, zu einem
|
||||
// fremden Projekt zu gehoeren.
|
||||
//
|
||||
// null bleibt es nur bei einem ungebundenen Zugang (Shared Key,
|
||||
// Session oder ein Alt-Token aus watchdog_agent_tokens). Dann
|
||||
// ueberschreibt der Ping eine frueher bekannte Zuordnung nicht,
|
||||
// sondern laesst sie stehen - siehe COALESCE in upsertHeartbeat().
|
||||
$pingProjekt = $pingKontext !== null ? ApiAuth::homeProject($pingKontext) : null;
|
||||
|
||||
$monitor = $monitorRepo->upsertHeartbeat(
|
||||
$source,
|
||||
$instance,
|
||||
@@ -78,7 +88,8 @@ switch ($action) {
|
||||
Http::input('checks'),
|
||||
// Welche Version laeuft hier? Optional - bestehende Agenten
|
||||
// schicken das Feld nicht und laufen unveraendert weiter.
|
||||
Http::str('version') ?? Http::str('app_version')
|
||||
Http::str('version') ?? Http::str('app_version'),
|
||||
$pingProjekt
|
||||
);
|
||||
|
||||
// Numerische Werte in den Verlauf uebernehmen, damit sich Trends
|
||||
@@ -110,6 +121,11 @@ switch ($action) {
|
||||
'last_status' => $monitor['last_status'],
|
||||
'last_seen_utc' => $monitor['last_seen_utc'],
|
||||
'app_version' => $monitor['app_version'] ?? null,
|
||||
// Wohin dieser Monitor gehoert. Steht hier null, erben seine
|
||||
// Befunde das Scheinprojekt "default" und sind unter dem
|
||||
// eigenen Projekt nicht auffindbar - der Melder soll das an
|
||||
// der Antwort sehen und nicht erst am ausbleibenden Befund.
|
||||
'project_slug' => $monitor['project_slug'] ?? null,
|
||||
'state_changed' => (bool)($monitor['_state_changed'] ?? false),
|
||||
'failing_checks' => $monitor['_failing_checks'] ?? [],
|
||||
],
|
||||
@@ -219,16 +235,29 @@ function requirePost(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft die Berechtigung, fuer eine bestimmte Source zu melden.
|
||||
* Prueft die Berechtigung, fuer eine bestimmte Source zu melden, und LIEFERT
|
||||
* DEN KONTEXT ZURUECK.
|
||||
*
|
||||
* Akzeptiert zentrale Tokens (dc_tokens, Scope watchdog:ping) und die
|
||||
* aelteren, an eine Source gebundenen Agent-Tokens.
|
||||
*
|
||||
* Die Rueckgabe ist neu: Vorher gab die Funktion void zurueck und warf den
|
||||
* Kontext weg - samt der Projektbindung des Tokens.
|
||||
* Genau die fehlte danach beim Anlegen des Monitors, und die Befunde landeten
|
||||
* im Scheinprojekt "default" (#404). Die Angabe wurde also gelesen und
|
||||
* anschliessend fallengelassen.
|
||||
*
|
||||
* null heisst "berechtigt, aber ohne Kontext": ein Alt-Token aus
|
||||
* watchdog_agent_tokens. Diese Token kennen kein Projekt.
|
||||
*
|
||||
* @return ?array Kontext aus ApiAuth::resolve(), oder null bei einem Alt-Token.
|
||||
*/
|
||||
function authorizeSource(PDO $db, string $source): void
|
||||
function authorizeSource(PDO $db, string $source): ?array
|
||||
{
|
||||
// Zentrale Token-Hierarchie, Shared Key oder Session
|
||||
if (ApiAuth::resolve($db, 'watchdog:ping', null, true) !== null) {
|
||||
return;
|
||||
$context = ApiAuth::resolve($db, 'watchdog:ping', null, true);
|
||||
if ($context !== null) {
|
||||
return $context;
|
||||
}
|
||||
|
||||
// Alt-Tokens aus watchdog_agent_tokens
|
||||
@@ -236,7 +265,7 @@ function authorizeSource(PDO $db, string $source): void
|
||||
if ($presented !== null) {
|
||||
$legacy = new LegacyTokenManager($db);
|
||||
if ($legacy->validateToken($presented, $source)) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,72 @@
|
||||
{
|
||||
"_comment": "Einzige Quelle der Wahrheit fuer den Changelog. Wird von /docs/changelog.php gerendert und von GET /api/updateservice/v1/changelog?since=X ausgeliefert. Neue Eintraege oben einfuegen.",
|
||||
"schema": 1,
|
||||
"current": "2.8.0",
|
||||
"current": "2.8.1",
|
||||
"versions": [
|
||||
{
|
||||
"version": "2.8.1",
|
||||
"date": "2026-08-31",
|
||||
"summary": "Sechs Befunde aus der Fremdmeldung, und fast alle gehen auf dieselbe Bauart zurueck: eine Voreinstellung, die einsprang, wo die richtige Antwort bekannt gewesen waere - meist das Projekt, an das der Aufrufer ohnehin gebunden ist. dc_next_task antwortete durchgaengig mit 500. Meldungen ohne project_slug landeten im Scheinprojekt \"default\" und waren danach fuer ihren eigenen Melder gesperrt; Watchdog-Monitore landeten dort auf einem zweiten Weg. Der Lesepfad lieferte ohne Slug alle Projekte statt des eigenen - ausgerechnet beim ersten Aufruf jeder Sitzung, und in einer Groesse, die das Kontextfenster fuellte. Und wo er richtig verengte, sagte er es nicht.",
|
||||
"actionRequired": true,
|
||||
"changes": [
|
||||
{
|
||||
"area": "api",
|
||||
"breaking": false,
|
||||
"title": "dc_next_task antwortete immer mit HTTP 500",
|
||||
"text": "POST manage?action=next scheiterte bei jedem Aufruf, mit und ohne Parameter, waehrend claim, release, list und stats am selben Endpunkt mit demselben Token durchliefen. Ursache war ein benannter Platzhalter, der in derselben Abfrage zweimal stand: BugRepo::buildFilter() setzte :project_scope fuer den Rueckkanal (fremde Items, die auf Nachpruefung warten) an zwei Stellen ein. Mit PDO::ATTR_EMULATE_PREPARES = false bereitet MySQL die Anweisung selbst vor, PDO bindet jeden Namen genau einmal - zwei Marker, ein Wert, SQLSTATE[HY093]. Getroffen war nur \"next\", weil nur dort der Rueckkanal-Filter gesetzt wird. Damit gab es keine exklusive Uebernahme; zwei Agenten konnten unbemerkt am selben Item arbeiten. Behoben durch zwei Platzhalter fuer denselben Wert.",
|
||||
"action": "Wer wegen des 500ers ohne Uebernahme gearbeitet hat: laufende Arbeit jetzt mit dc_next_task absichern."
|
||||
},
|
||||
{
|
||||
"area": "api",
|
||||
"breaking": true,
|
||||
"title": "Meldungen ohne project_slug gehen ins Heimatprojekt, nicht nach \"default\"",
|
||||
"text": "POST /report legte eine Meldung ohne project_slug im Projekt \"default\" ab. Das ist kein Projekt: Es hat keine dc_projects-Zeile, project_id blieb NULL, und das Item tauchte in keiner Projektansicht auf. Schlimmer noch war die Folge - dasselbe Token, das die Meldung angelegt hatte, bekam beim naechsten Zugriff 403 project_forbidden, weil es an sein eigenes Projekt gebunden ist. Der Server legte Items dort ab, wo der Melder sie nicht mehr anfassen durfte, und die Antwort sah wie ein Erfolg aus. Betroffen war der dokumentierte Normalfall: Das Weglassen des Slugs ist ausdruecklich vorgesehen. Ursache war eine Bedingung, die nur Laufzeit-Token auf ihr Heimatprojekt festnagelte; ein Werkbank-Token ohne Slug fiel durch bis zur Voreinstellung. Jetzt gilt: genannter Slug vor Heimatprojekt, und ohne beides eine Absage (400 missing_project) statt eines Ratens. Ein ausdruecklich genanntes, unbekanntes Projekt wird ebenfalls abgewiesen (400 unknown_project) statt stillschweigend angelegt. Dieselbe Falltuer war in POST /api/errors/v1/report und im WebUI-Formular offen und ist dort mitgeschlossen.",
|
||||
"action": "Bereits in \"default\" gelandete Items sind ueber die API nicht erreichbar und muessen mit Adminrechten umgehaengt oder geloescht werden."
|
||||
},
|
||||
{
|
||||
"area": "api",
|
||||
"breaking": true,
|
||||
"title": "Ohne project_slug gilt das eigene Projekt - beim Lesen wie beim Melden",
|
||||
"text": "dc_roadmap, dc_search, manage?action=list und ?action=stats lieferten ohne project_slug die Eintraege ALLER Projekte. Die Werkzeugbeschreibung sagte das Gegenteil (\"Ohne project_slug das eigene Projekt\"), was den Widerspruch lange verdeckt hat. Grund war eine Voreinstellung auf \"all\": Ein Werkbank-Token ist an ein Projekt gebunden, faellt beim Lesen aber absichtlich nicht unter die Projektschranke - es soll die Landschaft sehen duerfen. Damit war der Zwangsfilter leer, die Vorgabe griff, und ein Agent bekam einen fremden Backlog vorgelegt, den er nach derselben Regel nicht bearbeiten darf. Da CLAUDE.md dc_roadmap zum ersten Aufruf jeder Sitzung macht, war das der erste Eindruck. Jetzt gilt: genannter Slug, sonst Heimatprojekt, und erst ohne beides \"alle\". project_slug=all bleibt moeglich und ist damit eine Ansage statt eines Nebeneffekts.",
|
||||
"action": "Wer bisher auf die projektuebergreifende Antwort gebaut hat, schickt jetzt ausdruecklich project_slug=all."
|
||||
},
|
||||
{
|
||||
"area": "mcp",
|
||||
"breaking": true,
|
||||
"title": "dc_roadmap antwortet schmal statt vollstaendig",
|
||||
"text": "Die Roadmap lieferte je Eintrag den vollen Datensatz mit rund fuenfzig Feldern, darunter description, stack_trace, evidence und context_json - fuer 57 Eintraege rund 137.000 Zeichen. Ausgerechnet der erste Aufruf einer Sitzung fuellte damit das Kontextfenster, bevor die Arbeit begann. Eine Uebersicht beantwortet \"was ist dran und wer sitzt dran\"; dafuer braucht es keinen Stacktrace. dc_roadmap liefert deshalb standardmaessig die Uebersichtsfelder (BugRepo::COMPACT_FIELDS); Einzelheiten holt dc_get_item, die Begruendung steht ohnehin hinter doc_anchor. fields=full liefert weiterhin alles, dc_search hat fields=compact als Gegenstueck.",
|
||||
"action": null
|
||||
},
|
||||
{
|
||||
"area": "testkit",
|
||||
"breaking": false,
|
||||
"title": "dc-testkit 1.1.1 - und ein Riegel gegen die doppelt ausgelieferte Fassung",
|
||||
"text": "1.1.0 wurde zweimal ausgeliefert: mit 1898 und, im Zuge von 2.8.0, mit 1930 Zeilen. Der Unterschied sass vollstaendig in cmd_confirm - die aeltere Kopie kannte nur den Weg ueber status=closed, nicht den ueber verified_in_build. Jede Nachpruefung von so einer Maschine landete still im alten Verfahren, und selfupdate sah \"gleiche Fassung\" und tat nichts. Dasselbe war 1.0.0 schon passiert (1215 und 1335 Zeilen). Der Absatz ueber DC_TESTKIT_VERSION stand die ganze Zeit im Skript; er hat nicht getragen. scripts/deploy.py vergleicht jetzt vor dem Hochladen gegen den zuletzt ausgelieferten Stand und bricht ab, wenn sich der Inhalt geaendert hat, waehrend die Fassungsnummer stehen blieb.",
|
||||
"action": "Auf allen Testmaschinen dc-testkit selfupdate ausfuehren; erwartet wird danach 1.1.1."
|
||||
},
|
||||
{
|
||||
"area": "docs",
|
||||
"breaking": false,
|
||||
"title": "Die SDK-Fassung wird gemeldet, nicht abgeschrieben",
|
||||
"text": "Die Agentenvorlage zeigte die Zeile \"Deploymentcenter-Anbindung: SDK 2.5.1\" mit einer konkreten Zahl. Sie war als Beispiel gemeint und wurde als Wert uebernommen - in Projekt-CLAUDE.md-Dateien stand danach 2.5.1, waehrend dc_link_project laengst 2.6.0 auswies. Eine Angabe, die an N Stellen von Hand nachgezogen werden muss, ist nach dem ersten Vergessen falsch, und zwar schweigend. Die Vorlage nennt jetzt dc_link_project als die belastbare Auskunft und benutzt sichtbare Platzhalter, wo eine Fassung hingehoert.",
|
||||
"action": null
|
||||
},
|
||||
{
|
||||
"area": "api",
|
||||
"breaking": false,
|
||||
"title": "Watchdog-Monitore erben das Projekt ihres Ping-Tokens",
|
||||
"text": "watchdog_monitors.project_slug gibt es seit Migration 014, geschrieben hat die Spalte nie jemand: upsertHeartbeat() bekam das Projekt gar nicht erst uebergeben. Der Monitor entstand mit project_slug NULL, und jeder daraus abgeleitete Supervisor-Befund fiel auf das Scheinprojekt \"default\" zurueck - dieselbe Endstation wie bei den Meldungen ohne project_slug, nur ueber einen anderen Weg. Wirkung: dc_findings(project_slug=...) lieferte fuer eine laufende, meldende Instanz null Befunde. Wer dem Leitfaden folgt und dort anfaengt, schliesst daraus \"nichts auffaellig\" - waehrend ein deviation.self-Befund mit elf Vorkommnissen bereitstand. Die Zuordnung war dabei die ganze Zeit bekannt: Das Ping-Token ist an ein Projekt gebunden, und authorizeSource() liest die Bindung ohnehin - sie wurde nur nicht weitergereicht. Das Projekt kommt jetzt aus der Token-Bindung, nie aus dem Body, und ein Ping ohne Bindung (Shared Key, Alt-Token) ueberschreibt eine bekannte Zuordnung nicht. Die Ping-Antwort nennt project_slug.",
|
||||
"action": "Bestandsmonitore behalten project_slug NULL, bis ihre Anwendung das naechste Mal mit einem projektgebundenen Token pingt."
|
||||
},
|
||||
{
|
||||
"area": "api",
|
||||
"breaking": false,
|
||||
"title": "Ein verengter Leseweg sagt jetzt, dass er verengt hat",
|
||||
"text": "Ein an Projekt X gebundenes Token, das nach Projekt Y fragte, bekam beim Schreiben eine klare Absage (403 project_forbidden) und beim Lesen HTTP 200 - mit den Items von X und einem Umschlag, in dem der Projektfilter bereits auf X umgeschrieben war. Die Antwort sah damit aus wie eine gueltige Auskunft ueber Y. Das zaehlt, weil die Prompt-Vorlage jeden Agenten vor jeder Meldung durch dc_search schickt (\"gibt es das schon?\"): Auf \"gibt es das im Deploymentcenter schon?\" kam eine leere, plausibel aussehende Antwort - und das Duplikat wurde angelegt. Einem Melder ist genau das beinahe passiert; tatsaechlich waren es 71 Items. Die Schranke selbst bleibt unveraendert; list, stats und die Roadmap legen bei einer Verengung jetzt scoped_to und scope_note in den Umschlag. Ohne Verengung steht dort nichts - ein Hinweis, der immer kommt, wird nicht gelesen.",
|
||||
"action": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.8.0",
|
||||
"date": "2026-08-31",
|
||||
|
||||
+19
-1
@@ -1018,9 +1018,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
dc_redirect('#tab-bugtracker');
|
||||
}
|
||||
|
||||
// Das Formular hat eine Pflichtauswahl ueber die echten Projekte;
|
||||
// leer kommt hier nur an, wenn jemand am Formular vorbei postet.
|
||||
// Frueher wurde daraus stillschweigend 'default' - ein Projekt,
|
||||
// das es nicht gibt, mit project_id NULL und einem Item, das in
|
||||
// keiner Ansicht mehr auftauchte.
|
||||
$btZiel = trim((string)($_POST['project_slug'] ?? ''));
|
||||
|
||||
if ($btZiel === '') {
|
||||
dc_flash('Bitte ein Projekt auswaehlen.', 'danger');
|
||||
dc_redirect('#tab-bugtracker');
|
||||
}
|
||||
|
||||
$bugRepo = new BugRepo($pdo);
|
||||
|
||||
if (!$bugRepo->projectExists($btZiel)) {
|
||||
dc_flash(sprintf('Das Projekt "%s" gibt es nicht.', $btZiel), 'danger');
|
||||
dc_redirect('#tab-bugtracker');
|
||||
}
|
||||
|
||||
$result = $bugRepo->reportItem([
|
||||
'project_slug' => trim((string)($_POST['project_slug'] ?? 'default')),
|
||||
'project_slug' => $btZiel,
|
||||
'type' => (string)($_POST['type'] ?? 'bug'),
|
||||
'title' => $title,
|
||||
'description' => trim((string)($_POST['description'] ?? '')),
|
||||
|
||||
@@ -34,7 +34,19 @@ set -euo pipefail
|
||||
# die ausgelieferte Fassung die fehlenden Rechte des Tokens laengst angemeckert
|
||||
# haette. Dieselbe Falle, in die vorher die Serverfassung getappt ist (2.0.0 in
|
||||
# der Konfiguration, waehrend 2.7.0 lief).
|
||||
DC_TESTKIT_VERSION="1.1.0"
|
||||
#
|
||||
# UND SIE IST TROTZ DIESES ABSATZES WIEDER ZUGESCHNAPPT. 1.1.0 wurde zweimal
|
||||
# ausgeliefert: einmal mit 1898 Zeilen, dann - im Zuge von 2.8.0 - mit 1930.
|
||||
# Der Unterschied sass vollstaendig in cmd_confirm: die aeltere Kopie kannte
|
||||
# nur den alten Weg ueber status=closed, nicht den ueber verified_in_build.
|
||||
# Jede Nachpruefung von so einer Maschine landete still im alten Verfahren.
|
||||
# selfupdate sah "gleiche Fassung" und tat nichts.
|
||||
#
|
||||
# Ein Absatz allein traegt diese Regel offensichtlich nicht. Seit 2.8.1 prueft
|
||||
# scripts/deploy.py vor dem Hochladen, ob sich der Inhalt gegenueber dem
|
||||
# zuletzt ausgelieferten Stand geaendert hat, waehrend diese Zahl stehen blieb
|
||||
# - und bricht dann ab.
|
||||
DC_TESTKIT_VERSION="1.1.1"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Konfiguration
|
||||
|
||||
Reference in New Issue
Block a user