Compare commits

...
6 Commits
Author SHA1 Message Date
Spark 4a947747f2 0.26.0 — panneau Spark intégré (barre commune, assets vendangés)
CI / test (push) Failing after 1m56s
- static/js/draco-panneau.js + static/css/draco-panneau.css (copie
  du panneau partagé : conversation, approbations, missions J5)
- _draco_bar.html : action spark → DRACO_PANNEAU.toggle()
2026-10-22 03:39:34 +00:00
Spark 5ef6c21756 feat(0.26.0): écritures MCP minimales — create_issue + add_comment gardés par jeton kbt_ 2026-10-22 03:31:19 +00:00
Spark 13182f5d53 0.25.0 — Jetons d'API personnels (kbt_) pour les surfaces machine
CI / test (push) Successful in 1m56s
Depuis 0.24.0, /mcp exige session ou Basic : le service d'agents s'y
faisait refuser en 401 (vu en production, panneau Spark du 2026-10-21).

- Table api_tokens (empreinte SHA-256 seule), CRUD dans Mon profil
- Bearer honoré par le middleware sur /mcp et /api/ uniquement —
  jamais l'interface web ; révocation et désactivation immédiates
- 235 tests verts (3 nouveaux : cycle de vie, compte désactivé,
  handshake MCP par jeton quand l'auth est active)
2026-10-21 21:38:16 -04:00
Spark 2381ae0b35 fix(0.24.2): base sombre pour tous les champs de saisie (#29)
CI / test (push) Successful in 1m54s
2026-10-21 21:31:57 -04:00
bruno 7b6e72e6ce chore: APP_VERSION 0.24.1
CI / test (push) Successful in 1m53s
2026-10-21 15:31:43 -04:00
bruno 6e31122c46 fix: cryptography manquant dans dependencies (forge_crypto.py) — crash ModuleNotFoundError au démarrage v0.24.0
CI / test (push) Successful in 1m53s
2026-10-21 15:26:40 -04:00
15 changed files with 1210 additions and 44 deletions
+87
View File
@@ -1,5 +1,92 @@
# Changelog
## 0.26.0 — 2026-10-21
### Ajouté — panneau Spark intégré
Le bouton Spark de la barre commune ouvre désormais le panneau de
l'agent commun (conversation, Aujourd'hui, approbations, missions),
comme dans TikTak et Flowdeck : assets `draco-panneau.js/css`
vendangés à l'identique, action `spark` branchée sur
`DRACO_PANNEAU.toggle()`. Redéploiement Docker requis (avec le reste
de la 0.26.0).
### Ajouté — écritures MCP minimales et sûres (service d'agents)
Le serveur MCP n'était qu'en lecture depuis la 0.5.0. Le service
d'agents (jeton `kbt_` posé en production) peut désormais créer
des cartes et commenter, et rien d'autre : aucune suppression,
aucun changement d'état.
- **`create_issue`** — arguments : `owner` (chaîne, requis),
`repo` (chaîne, requis), `title` (chaîne, requis), `body`
(chaîne, optionnel), `column` (optionnel, une des colonnes du
board, `backlog` par défaut). Réutilise le service de création
existant (`_do_capture` → `create_issue` du client de forge,
Gitea ou GitHub selon la connexion qui détient le dépôt) : la
carte est placée sur le board et journalisée (source `mcp`).
Réponse JSON : `number`, `title`, `html_url`, `repo_full_name`,
`column`.
- **`add_comment`** — arguments : `owner`, `repo`, `number`
(entier), `body` — tous requis. Réutilise `create_comment` du
client de forge (mêmes validations que l'API web : corps non
vide, 10 000 caractères max) et journalise l'événement
`commented` (source `mcp`). Réponse JSON : `id`, `body`,
`html_url`, `repo_full_name`, `number`.
- **Garde inchangée** : les deux outils passent par le même
`/mcp` et le même middleware que les lectures — session,
Basic ou `Authorization: Bearer kbt_…` ; un jeton ne
déverrouille toujours pas l'interface web, et sa révocation
bloque aussitôt les écritures. Erreurs métier au format
existant du serveur (résultat `isError` avec le message).
- `GET /mcp` déclare désormais `read_only: false` et la liste
`write_tools` (honnêteté de la découverte).
- **Redéploiement Docker de Kanbante à la charge de Bruno**
pour activer cette version en production.
## 0.25.0 — 2026-10-21
### Ajouté — jetons d'API personnels pour les surfaces machine (MCP)
Depuis la 0.24.0, tout Kanbante exige une authentification (session
ou Basic) — y compris `/mcp`. Le service d'agents s'y faisait refuser
en 401 (vu en production le 2026-10-21, panneau Spark : « Source
Kanbante injoignable »). Il manquait une identité machine propre.
- Table `api_tokens` : jetons personnels préfixés `kbt_`, seule
l'empreinte SHA-256 est conservée ; création, liste et révocation
dans **Mon profil → Jetons d'API (MCP)**, texte clair affiché une
seule fois à la création.
- Le middleware honore `Authorization: Bearer kbt_…` **uniquement**
sur `/mcp` et `/api/` : un jeton ne déverrouille jamais l'interface
web. Révocation et désactivation du compte ont effet immédiat ;
`last_used_at` journalise l'usage.
- Usage visé : créer un jeton « service-agents » et le poser en
`AGENT_MCP_KANBANTE_KEY` dans le `.env` du conteneur agent.
## 0.24.2 — 2026-10-21
### Corrigé — boîtes d'édition blanches dans l'administration (#29)
Signalé par Bruno (captures) : le champ « Nouveau mot de passe »
(Administration), la zone « Sélection de projets » (Espaces) et
les champs « Valeur du panneau » (Paramètres) s'affichaient en
**blanc**. Il n'existait aucune règle de base pour les champs de
saisie : seuls les champs couverts par un contexte précis
(`.login-form`, `.capture-form`, `.admin-form` pour text/password/
select seulement) étaient sombres — les textarea et tout champ
hors de ces contextes gardaient le fond du navigateur.
Correctif dans `style.css` : une **règle de base unique en
spécificité zéro** (`:where(...)`) — fond `--surface-2`, texte
`--text`, bordure `--border` — pour `input`, `textarea` et
`select`, qui comble les vides sans jamais écraser les règles de
contexte existantes ; plus `color-scheme: dark` pour les
contrôles natifs. Styles calculés vérifiés sur les trois pages
(fond identique aux champs de référence, palette de commandes
inchangée).
## 0.24.0 — 2026-10-21
### Ajouté — multi-utilisateurs, multi-forges, espaces, archives
+13 -7
View File
@@ -33,7 +33,7 @@
- 🔀 **Réconciliation par étiquettes** — si l'étiquette `kanban:*` d'une issue change hors de Kanbante (dans Gitea, par un autre outil), le board adopte la colonne de l'étiquette au chargement et le journalise (source `gitea`). Une seule étiquette `kanban:*` fait foi ; aucune ou plusieurs = l'état local gagne
- 🔍 **Recherche globale + Mes issues** — chercher dans toutes les issues de tous les projets (`/search`), et voir ce qui vous est assigné (`/my`)
- 📊 **Briefing quotidien** — encours, vieillissement, milestones proches : page `/briefing` et envoi **ntfy** planifié
- 🤖 **Serveur MCP** — 7 outils en lecture seule (boards, focus, recherche, métriques) exposés en JSON-RPC sur `/mcp` pour vos agents
- 🤖 **Serveur MCP** — 7 outils de lecture (boards, focus, recherche, métriques) + 2 écritures minimales (`create_issue`, `add_comment`) exposés en JSON-RPC sur `/mcp` pour vos agents
- 🏷️ **Étiquettes gérables depuis le board** — créer / supprimer les étiquettes du dépôt, les appliquer aux issues depuis le panneau détail
- 📝 **Notes de projet** — panneau repliable, largeur ajustable, sauvegarde automatique
- 📝 **Notes** — bloc-notes par projet
@@ -212,15 +212,17 @@ kanbante/
| GET | `/briefing` | Briefing quotidien (page lisible + statut ntfy) |
| GET | `/api/briefing` | Briefing en JSON + texte formaté |
| POST | `/api/briefing/send` | Envoyer le briefing sur ntfy maintenant |
| GET/POST | `/mcp` | Serveur MCP lecture seule (JSON-RPC 2.0) |
| GET/POST | `/mcp` | Serveur MCP (JSON-RPC 2.0) : lectures + `create_issue` / `add_comment` |
| GET/POST | `/api/repos/{o}/{r}/labels` | Lister / créer les étiquettes du dépôt |
| DELETE | `/api/repos/{o}/{r}/labels/{id}` | Supprimer une étiquette du dépôt |
| POST | `/api/issues/{o}/{r}/{n}/labels` | Ajouter / retirer une étiquette sur une issue |
## Brancher un client MCP
Kanbante expose un serveur MCP **en lecture seule** (aucune écriture :
les lectures d'abord). Configuration type (transport HTTP) :
Kanbante expose un serveur MCP : lectures d'abord, plus deux
écritures minimales et sûres pour les agents — créer une carte et
commenter (aucune suppression, aucun changement d'état).
Configuration type (transport HTTP) :
```json
{
@@ -233,9 +235,13 @@ les lectures d'abord). Configuration type (transport HTTP) :
```
Outils : `list_projects`, `get_board`, `get_issue`, `get_focus`,
`search_issues`, `get_activity`, `get_metrics`. Si la Basic Auth
Kanbante est active, le client doit envoyer l'en-tête
`Authorization` correspondant.
`search_issues`, `get_activity`, `get_metrics`, `create_issue`
(`owner`, `repo`, `title`, `body` et `column` optionnels),
`add_comment` (`owner`, `repo`, `number`, `body`). Si
l'authentification Kanbante est active, le client doit envoyer
l'en-tête `Authorization` correspondant : Basic, ou un jeton
d'API personnel `Bearer kbt_…` (Mon profil → Jetons d'API) —
honoré uniquement sur `/mcp` et `/api/`, jamais sur le web.
## Activer le temps réel (webhook Gitea)
+1 -1
View File
@@ -104,7 +104,7 @@ from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
APP_VERSION = "0.24.0"
APP_VERSION = "0.26.0"
def _env_bool(name: str, default: bool) -> bool:
+200 -32
View File
@@ -308,6 +308,18 @@ def _basic_ok(request: Request) -> bool:
) and secrets.compare_digest(password, settings.auth_password)
def _bearer_user(request: Request) -> dict | None:
"""Jeton d'API personnel (0.25.0) : Authorization: Bearer kbt_…
Réservé aux surfaces machine (/mcp, /api/) — le middleware ne
l'honore que là. La résolution vérifie l'empreinte, la révocation
et que le compte est actif, et journalise l'usage (last_used_at)."""
header = request.headers.get("authorization", "")
if not header.lower().startswith("bearer "):
return None
return models.user_by_api_token(header[7:].strip())
def _current_user(request: Request) -> dict | None:
"""The database user behind the session, or None.
@@ -367,6 +379,11 @@ class AuthMiddleware(BaseHTTPMiddleware):
session_user = None # disabled account: session is dead
if session_user is not None or _basic_ok(request):
return await call_next(request)
# Jetons d'API (0.25.0) : surfaces machine seulement — un jeton
# ne déverrouille jamais l'interface web.
if (path.startswith("/mcp") or path.startswith("/api/")) \
and _bearer_user(request) is not None:
return await call_next(request)
# API consumers get a plain 401 (no browser prompt, no
# redirect they cannot follow); browsers get the login page.
if path.startswith("/api/") or "text/html" not in request.headers.get(
@@ -1541,11 +1558,8 @@ async def admin_settings_save(request: Request, csrf_token: str = Form("")):
)
@app.get("/profile")
async def profile_page(request: Request, msg: str = "", err: str = ""):
user = _current_user(request)
if user is None:
return RedirectResponse("/login", status_code=303)
def _render_profile(request: Request, user: dict, msg: str = "", err: str = "",
nouveau_jeton: str = ""):
from app import forge_crypto
mine = []
@@ -1566,10 +1580,64 @@ async def profile_page(request: Request, msg: str = "", err: str = ""):
else []
),
my_connections=mine,
api_tokens=(
models.list_api_tokens(user["id"])
if user.get("id") is not None
else []
),
nouveau_jeton=nouveau_jeton,
msg=msg, err=err,
)
@app.get("/profile")
async def profile_page(request: Request, msg: str = "", err: str = ""):
user = _current_user(request)
if user is None:
return RedirectResponse("/login", status_code=303)
return _render_profile(request, user, msg=msg, err=err)
@app.post("/profile/tokens")
async def profile_token_create(
request: Request,
name: str = Form(""),
csrf_token: str = Form(""),
):
user = _current_user(request)
if user is None or user.get("id") is None:
return RedirectResponse("/profile", status_code=303)
if not _csrf_valid(request, csrf_token):
return _csrf_error()
nom = name.strip() or "Sans nom"
_row, plaintext = models.create_api_token(user["id"], nom)
models.log_admin(user["id"], "profile.token.create", nom)
# Le texte clair n'est montré qu'ici, une seule fois : la base ne
# conserve que l'empreinte.
return _render_profile(
request, user,
msg="Jeton créé — copie-le maintenant, il ne sera plus jamais affiché.",
nouveau_jeton=plaintext,
)
@app.post("/profile/tokens/{token_id}/revoke")
async def profile_token_revoke(
request: Request,
token_id: int,
csrf_token: str = Form(""),
):
user = _current_user(request)
if user is None or user.get("id") is None:
return RedirectResponse("/profile", status_code=303)
if not _csrf_valid(request, csrf_token):
return _csrf_error()
if models.revoke_api_token(user["id"], token_id):
models.log_admin(user["id"], "profile.token.revoke", str(token_id))
return RedirectResponse("/profile?msg=Jeton+r%C3%A9voqu%C3%A9", status_code=303)
return RedirectResponse("/profile?err=Jeton+introuvable", status_code=303)
@app.post("/profile")
async def profile_update(
request: Request,
@@ -4281,27 +4349,14 @@ async def sse_event_stream(request: Request):
hub.unsubscribe(queue)
async def _do_capture(
title: str, body: str, repo_full_name: str, column: str = "backlog"
) -> dict:
"""Create a Gitea issue from a capture and place its card.
async def _client_for_repo(repo_full_name: str):
"""(client, connection_id) holding this repo, default first.
Shared by POST /api/capture, the /capture page and the ntfy
listener. Raises ValueError for bad input (callers map to 400).
Board-less callers (capture, serveur MCP) have no request /
workspace scope: the connection holding the repo among the
global ones wins, so a card can target a GitHub project too
(0.24.0). Falls back to the historical client.
"""
title = (title or "").strip()
if not title or len(title) > 255:
raise ValueError("Title required (max 255 chars)")
if column not in KANBAN_COLUMNS:
raise ValueError("Invalid column")
parts = _split_full_name(repo_full_name)
if parts is None:
raise ValueError("Invalid repo (expected owner/name)")
owner, repo = parts
# Capture has no board context: find the connection holding
# this repo among the global ones (default first), so a card
# can be captured into a GitHub project too (0.24.0).
client, conn_id = gitea, registry.default_id
for candidate in registry.global_clients():
if candidate is gitea:
continue
@@ -4312,8 +4367,33 @@ async def _do_capture(
except Exception:
continue
if repo_full_name in names:
client, conn_id = candidate, candidate.connection_id
break
return candidate, candidate.connection_id
return gitea, registry.default_id
async def _do_capture(
title: str,
body: str,
repo_full_name: str,
column: str = "backlog",
source: str = "capture",
) -> dict:
"""Create a Gitea issue from a capture and place its card.
Shared by POST /api/capture, the /capture page, the ntfy
listener and the MCP create_issue tool (0.26.0, source="mcp").
Raises ValueError for bad input (callers map to 400).
"""
title = (title or "").strip()
if not title or len(title) > 255:
raise ValueError("Title required (max 255 chars)")
if column not in KANBAN_COLUMNS:
raise ValueError("Invalid column")
parts = _split_full_name(repo_full_name)
if parts is None:
raise ValueError("Invalid repo (expected owner/name)")
owner, repo = parts
client, conn_id = await _client_for_repo(repo_full_name)
issue = await client.create_issue(owner, repo, title, body or "")
# La carte vit dans le premier espace qui détient la connexion.
ws_id = next(
@@ -4330,7 +4410,7 @@ async def _do_capture(
)
log_event(
repo_full_name, issue["number"], "created",
to_column=column, source="capture",
to_column=column, source=source,
workspace_id=ws_id, connection_id=conn_id,
)
await _publish_board_changed(repo_full_name)
@@ -5296,7 +5376,11 @@ async def api_toggle_issue_label(
}
# ── MCP server (read-only tools over JSON-RPC) ─────────────
# ── MCP server (tools over JSON-RPC) ───────────────────────
# Lectures depuis la 0.5.0 ; écritures minimales (create_issue,
# add_comment) depuis la 0.26.0, gardées par la même authentification
# que tout /mcp (session, Basic ou jeton d'API kbt_ — voir le
# middleware : un jeton n'ouvre jamais l'interface web).
MCP_TOOLS = [
{
@@ -5362,11 +5446,49 @@ MCP_TOOLS = [
"properties": {"repo": {"type": "string"}},
},
},
{
"name": "create_issue",
"description": "Create an issue (card) in a repo and place it on "
"the board (backlog by default). Write tool — same /mcp "
"authentication as the reads (kbt_ API token, session or Basic).",
"inputSchema": {
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"title": {"type": "string"},
"body": {"type": "string"},
"column": {"type": "string", "enum": list(KANBAN_COLUMNS)},
},
"required": ["owner", "repo", "title"],
},
},
{
"name": "add_comment",
"description": "Add a comment to an existing issue (card). "
"Write tool — same /mcp authentication as the reads "
"(kbt_ API token, session or Basic).",
"inputSchema": {
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"number": {"type": "integer"},
"body": {"type": "string"},
},
"required": ["owner", "repo", "number", "body"],
},
},
]
async def _mcp_call_tool(name: str, args: dict) -> str:
"""Execute one read-only MCP tool and return text content."""
"""Execute one MCP tool and return text content.
Reads hit the forge's read API; the two write tools
(create_issue, add_comment) reuse the existing creation /
comment services — never a reimplemented forge call.
"""
if name == "list_projects":
repos = await gitea.search_repos()
active, inactive, archived = classify_repos(repos)
@@ -5443,6 +5565,49 @@ async def _mcp_call_tool(name: str, args: dict) -> str:
)
if name == "get_metrics":
return json.dumps(get_metrics(args.get("repo") or None), ensure_ascii=False)
if name == "create_issue":
owner, repo = args["owner"], args["repo"]
created = await _do_capture(
str(args["title"]),
str(args.get("body") or ""),
f"{owner}/{repo}",
str(args.get("column") or "backlog"),
source="mcp",
)
return json.dumps(created, ensure_ascii=False)
if name == "add_comment":
owner, repo = args["owner"], args["repo"]
number = int(args["number"])
body = str(args["body"] or "").strip()
if not body:
raise ValueError("Comment must not be empty")
if len(body) > 10_000:
raise ValueError("Comment too large (max 10000 chars)")
full_name = f"{owner}/{repo}"
client, conn_id = await _client_for_repo(full_name)
comment = await client.create_comment(owner, repo, number, body)
ws_id = next(
(
ws["id"]
for ws in models.list_workspaces()
if conn_id in models.workspace_connection_ids(ws["id"])
),
1,
)
log_event(
full_name, number, "commented", source="mcp",
workspace_id=ws_id, connection_id=conn_id,
)
return json.dumps(
{
"id": comment.get("id"),
"body": comment.get("body", body),
"html_url": comment.get("html_url", ""),
"repo_full_name": full_name,
"number": number,
},
ensure_ascii=False,
)
raise ValueError(f"Unknown tool: {name}")
@@ -5453,7 +5618,8 @@ async def mcp_info():
"name": "kanbante",
"version": APP_VERSION,
"transport": "streamable-http (JSON-RPC 2.0 over POST)",
"read_only": True,
"read_only": False,
"write_tools": ["create_issue", "add_comment"],
"tools": [t["name"] for t in MCP_TOOLS],
}
@@ -5462,8 +5628,10 @@ async def mcp_info():
async def mcp_endpoint(request: Request):
"""Minimal MCP server: initialize / tools/list / tools/call / ping.
Read-only by design (Bruno's rule: cross-platform reads first,
writes go through approval flows elsewhere).
Reads since 0.5.0; since 0.26.0, two minimal write tools
(create_issue, add_comment) for the agent service, guarded by
the same /mcp authentication as the reads (session, Basic or
kbt_ API token — a token never unlocks the web UI).
"""
try:
payload = await request.json()
+79
View File
@@ -11,8 +11,10 @@ and upgrades older databases in place (position column, notes
updated_at, cleanup of rows carrying an unknown column name).
"""
import hashlib
import json
import os
import secrets
import sqlite3
from contextlib import contextmanager
from typing import Iterator
@@ -134,6 +136,16 @@ def init_db() -> None:
last_login_at TEXT
);
CREATE TABLE IF NOT EXISTS api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
name TEXT NOT NULL DEFAULT '',
token_hash TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_used_at TEXT,
revoked_at TEXT
);
CREATE TABLE IF NOT EXISTS workspaces (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
@@ -900,6 +912,73 @@ def get_user_by_username(username: str) -> dict | None:
return dict(row) if row else None
# --- Jetons d'API (0.25.0) -------------------------------------------------
# Jetons personnels pour les consommateurs machine (le service d'agents
# et son client MCP, en premier lieu). Le texte clair (préfixe kbt_)
# n'existe qu'à la création ; la base ne garde que l'empreinte SHA-256.
# Un jeton authentifie uniquement les surfaces machine (/mcp, /api/) —
# jamais l'interface web.
API_TOKEN_PREFIX = "kbt_"
def _hash_api_token(plaintext: str) -> str:
return hashlib.sha256(plaintext.encode("utf-8")).hexdigest()
def create_api_token(user_id: int, name: str) -> tuple[dict, str]:
plaintext = API_TOKEN_PREFIX + secrets.token_urlsafe(32)
with get_db() as conn:
cur = conn.execute(
"INSERT INTO api_tokens (user_id, name, token_hash) VALUES (?, ?, ?)",
(user_id, name.strip(), _hash_api_token(plaintext)),
)
row = conn.execute(
"SELECT * FROM api_tokens WHERE id = ?", (cur.lastrowid,)
).fetchone()
return dict(row), plaintext
def list_api_tokens(user_id: int) -> list[dict]:
with get_db() as conn:
return [
dict(r)
for r in conn.execute(
"SELECT * FROM api_tokens WHERE user_id = ? ORDER BY id",
(user_id,),
).fetchall()
]
def revoke_api_token(user_id: int, token_id: int) -> bool:
with get_db() as conn:
cur = conn.execute(
"UPDATE api_tokens SET revoked_at = datetime('now')"
" WHERE id = ? AND user_id = ? AND revoked_at IS NULL",
(token_id, user_id),
)
return cur.rowcount > 0
def user_by_api_token(plaintext: str) -> dict | None:
if not plaintext or not plaintext.startswith(API_TOKEN_PREFIX):
return None
with get_db() as conn:
row = conn.execute(
"SELECT u.* FROM api_tokens t JOIN users u ON u.id = t.user_id"
" WHERE t.token_hash = ? AND t.revoked_at IS NULL AND u.active = 1",
(_hash_api_token(plaintext),),
).fetchone()
if row is None:
return None
conn.execute(
"UPDATE api_tokens SET last_used_at = datetime('now')"
" WHERE token_hash = ?",
(_hash_api_token(plaintext),),
)
return dict(row)
def get_user_by_draco(sub: str) -> dict | None:
with get_db() as conn:
row = conn.execute(
+7 -1
View File
@@ -6,6 +6,7 @@
Inclus par _header.html (toutes les pages applicatives ; ni le
wallboard ni l'écran de connexion, volontaires). #}
<link rel="stylesheet" href="/static/css/draco-barre.css?v={{ version }}">
<link rel="stylesheet" href="/static/css/draco-panneau.css?v={{ version }}">
<script>document.body.classList.add('has-draco-bar');</script>
<header class="draco-bar" data-current="kanbante">
<div class="draco-bar-zone draco-bar-gauche">
@@ -86,7 +87,11 @@
if (act === "palette") { var p = document.querySelector(".palette-trigger"); if (p) { p.click(); } else { dire("Recherche & commandes : Ctrl+K."); } return; }
if (act === "alertes") { dire("Alertes unifiées : arrive avec la fédération (programme Fusion)."); return; }
if (act === "menu") { dire("Menu commun : arrive avec la fédération (programme Fusion)."); return; }
if (act === "spark") { dire("Spark, l'agent commun : arrive en phase 4 du programme Fusion."); return; }
if (act === "spark") {
if (window.DRACO_PANNEAU) { window.DRACO_PANNEAU.toggle(); }
else { dire("Spark : le panneau n'a pas pu se charger."); }
return;
}
if (act === "rappel-entete") {
var bar = document.querySelector(".draco-bar");
if (bar) bar.style.display = "";
@@ -96,3 +101,4 @@
};
</script>
<script defer src="/static/js/draco-barre.js?v={{ version }}"></script>
<script defer src="/static/js/draco-panneau.js?v={{ version }}"></script>
+37
View File
@@ -46,6 +46,43 @@
{% endif %}
</section>
<section class="briefing-card">
<h2>🗝️ Jetons d'API (MCP)</h2>
<p class="muted">Pour les consommateurs machine — le service d'agents, en premier lieu. Un jeton (préfixe <code>kbt_</code>) authentifie uniquement <code>/mcp</code> et <code>/api/</code>, jamais l'interface web. Il se présente en en-tête <code>Authorization: Bearer kbt_…</code>. Le texte clair n'est affiché qu'à la création ; ensuite, seule son empreinte est conservée.</p>
{% if profile_user.id %}
{% if nouveau_jeton %}
<p>✅ Nouveau jeton — <strong>copie-le maintenant</strong> :</p>
<p><code style="font-size:15px">{{ nouveau_jeton }}</code></p>
{% endif %}
{% if api_tokens %}
<ul>
{% for t in api_tokens %}
<li>
<strong>{{ t.name }}</strong> · créé le {{ t.created_at }}
· {% if t.last_used_at %}dernier usage : {{ t.last_used_at }}{% else %}jamais utilisé{% endif %}
{% if t.revoked_at %} · <span class="chip-warn">révoqué le {{ t.revoked_at }}</span>
{% else %}
<form method="post" action="/profile/tokens/{{ t.id }}/revoke" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-mini">Révoquer</button>
</form>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">Aucun jeton pour l'instant.</p>
{% endif %}
<form method="post" action="/profile/tokens" class="admin-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Nom du jeton <input type="text" name="name" placeholder="service-agents" required></label>
<button type="submit" class="btn-save btn-inline">Créer un jeton</button>
</form>
{% else %}
<p class="muted">Compte défini par l'environnement (KANBANTE_AUTH_*) : les jetons appartiennent aux comptes de la base — connecte-toi avec ton compte personnel pour en créer un.</p>
{% endif %}
</section>
<section class="briefing-card">
<h2>✦ Liaison draco (SSO)</h2>
{% if profile_user.draco_sub %}
+2 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "kanbante"
version = "0.24.0"
version = "0.26.0"
description = "Kanban project manager integrated with Gitea"
requires-python = ">=3.11"
dependencies = [
@@ -10,6 +10,7 @@ dependencies = [
"httpx>=0.28",
"python-dotenv>=1.0",
"python-multipart>=0.0.18",
"cryptography>=42",
]
[tool.setuptools.packages.find]
+83
View File
@@ -0,0 +1,83 @@
/* Panneau Spark — branchement au service d'agents (Fusion, jalon J1).
Composant d'intégration TikTak : le panneau définitif sera vendangé
depuis dracodev-commons ; celui-ci prouve le chemin panneau → service. */
.draco-panneau {
position: fixed; top: 0; right: 0; height: 100dvh; width: 388px; max-width: 94vw;
background: #fbfbfa; color: #242624; z-index: 400;
box-shadow: -12px 0 36px rgb(0 0 0 / .22);
display: flex; flex-direction: column;
transform: translateX(105%); transition: transform .28s ease;
font: 14px/1.5 system-ui, "Segoe UI", sans-serif;
}
.draco-panneau.open { transform: translateX(0); }
.draco-panneau-hd {
display: flex; align-items: center; gap: 9px;
padding: 13px 15px; border-bottom: 1px solid #e4e5e0; background: #fff;
}
.draco-panneau-hd .titre { font-weight: 700; font-size: 15px; }
.draco-panneau-hd .soustitre { font-size: 12px; color: #6c7268; }
.draco-panneau-hd .fermer {
margin-left: auto; border: 0; background: #eef0ea; border-radius: 8px;
width: 30px; height: 30px; cursor: pointer; font-size: 14px;
}
.draco-panneau-etat { display: flex; align-items: center; gap: 7px; font-size: 12.5px; }
.draco-panneau-etat .point { width: 9px; height: 9px; border-radius: 50%; background: #b9bdb2; flex: none; }
.draco-panneau-etat.ok .point { background: #2ea36b; }
.draco-panneau-etat.err .point { background: #cf4b32; }
.draco-panneau-outils { display: flex; gap: 8px; padding: 9px 15px; border-bottom: 1px solid #e4e5e0; background: #fff; }
.draco-panneau-outils button {
border: 1px solid #d6d8d1; background: #f4f5f1; border-radius: 999px;
padding: 5px 12px; font: 600 12.5px system-ui; cursor: pointer;
}
.draco-panneau-outils button:hover { background: #e9ebe4; }
.draco-appro {
align-self: stretch; background: #fdf6ec; border: 1.6px solid #e8a13c;
border-radius: 12px; padding: 10px 12px;
}
.draco-appro .titre { font-weight: 700; font-size: 12px; color: #9a6a1c; text-transform: uppercase; letter-spacing: .4px; }
.draco-appro .resume { margin: 5px 0 8px; }
.draco-appro .btns { display: flex; gap: 7px; }
.draco-appro .btns button { border: 0; border-radius: 8px; padding: 6px 11px; font: 600 12.5px system-ui; cursor: pointer; background: #eef0ea; }
.draco-appro .btns button.go { background: #2ea36b; color: #fff; }
.draco-auj { align-self: stretch; background: #fff; border: 1px solid #e4e5e0; border-radius: 12px; padding: 10px 12px; }
.draco-auj h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: #6c7268; margin: 6px 0 4px; }
.draco-auj h5:first-child { margin-top: 0; }
.draco-auj ul { margin: 0; padding-left: 17px; }
.draco-auj .etat-source { color: #6c7268; font-size: 12.5px; }
.draco-panneau-msgs { flex: 1; overflow-y: auto; padding: 14px 15px; display: flex; flex-direction: column; gap: 9px; }
.draco-msg { max-width: 88%; padding: 9px 12px; border-radius: 13px; white-space: pre-wrap; word-wrap: break-word; }
.draco-msg.user { align-self: flex-end; background: #2f6fed; color: #fff; border-bottom-right-radius: 4px; }
.draco-msg.agent { align-self: flex-start; background: #eef0ea; border-bottom-left-radius: 4px; }
.draco-msg.sys { align-self: center; background: none; color: #6c7268; font-size: 12.5px; text-align: center; max-width: 96%; }
.draco-panneau-ft { border-top: 1px solid #e4e5e0; padding: 11px 12px; background: #fff; }
.draco-panneau-form { display: flex; gap: 8px; }
.draco-panneau-form input {
flex: 1; border: 1px solid #d6d8d1; border-radius: 10px; padding: 9px 11px; font: inherit;
}
.draco-panneau-form button {
border: 0; border-radius: 10px; background: #7c5cd6; color: #fff;
padding: 9px 14px; font: inherit; font-weight: 600; cursor: pointer;
}
.draco-panneau-form button:disabled { opacity: .5; cursor: default; }
.draco-panneau-note { font-size: 11.5px; color: #8a8f84; margin-top: 8px; text-align: center; }
.draco-missions { align-self: stretch; background: #fff; border: 1px solid #e4e5e0; border-radius: 12px; padding: 10px 12px; }
.draco-missions h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: #6c7268; margin: 0 0 6px; }
.draco-missions label { display: block; font-size: 12px; font-weight: 600; color: #6c7268; margin: 8px 0 3px; }
.draco-missions select, .draco-missions input, .draco-missions textarea {
width: 100%; box-sizing: border-box; border: 1px solid #d6d8d1; border-radius: 8px;
padding: 7px 9px; font: inherit; font-size: 13px; background: #fff;
}
.draco-missions .btns { display: flex; gap: 7px; margin-top: 10px; }
.draco-missions .btns button { border: 0; border-radius: 8px; padding: 6px 11px; font: 600 12.5px system-ui; cursor: pointer; background: #eef0ea; }
.draco-missions .btns button.go { background: #7c5cd6; color: #fff; }
.draco-missions .m-liste { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; }
.draco-missions .etat-source { color: #6c7268; font-size: 12.5px; }
.draco-missions .m-item { border: 1px solid #e4e5e0; border-radius: 10px; padding: 8px 10px; }
.draco-missions .m-entete { display: flex; justify-content: space-between; gap: 8px; align-items: baseline; }
.draco-missions .m-statut { font-size: 11.5px; font-weight: 700; color: #6c7268; white-space: nowrap; }
.draco-missions .m-statut.st-succeeded { color: #2ea36b; }
.draco-missions .m-statut.st-failed { color: #cf4b32; }
.draco-missions .m-statut.st-running, .draco-missions .m-statut.st-paused_question { color: #9a6a1c; }
.draco-missions .m-meta { font-size: 12px; color: #6c7268; }
.draco-missions .m-rapport { font-size: 12.5px; margin-top: 5px; white-space: pre-wrap; word-wrap: break-word; }
.draco-missions .m-annuler { margin-top: 6px; border: 0; border-radius: 7px; padding: 4px 9px; font: 600 12px system-ui; cursor: pointer; background: #f6e3de; color: #a03a28; }
+22
View File
@@ -60,6 +60,28 @@ a:hover { text-decoration: underline; }
button, input, select, textarea { font: inherit; }
/* — Base des champs de saisie (#29) : sans règle de fond, tout
champ hors d'un contexte stylé (.login-form, .capture-form,
.admin-form…) gardait le fond BLANC du navigateur — vu sur la
réinitialisation de mot de passe (Administration), la sélection
de projets (Espaces, textarea jamais couverte) et les valeurs
du panneau (Paramètres). Sélecteur en :where() = spécificité
zéro : cette base ne peut écraser AUCUNE règle de contexte
existante, elle ne fait que combler les vides. */
:root { color-scheme: dark; }
:where(input:not([type="checkbox"]):not([type="radio"]):not([type="color"]):not([type="file"]):not([type="hidden"]):not([type="submit"]):not([type="button"]):not([type="reset"]):not([type="image"]):not([type="range"]),
textarea, select) {
background: var(--surface-2);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 10px;
}
:where(input, textarea, select):focus-visible {
outline: 2px solid rgba(124, 92, 252, 0.55);
outline-offset: 0;
}
/* ── App header ─────────────────────────────── */
.app-header, header {
position: sticky;
+380
View File
@@ -0,0 +1,380 @@
/* Panneau Spark — branchement au service d'agents (Fusion, J1 → J5).
*
* Parle à l'API du service d'agents (contrat de dracodev-commons) :
* GET /api/v1/me — identité (cookie draco_session)
* POST /api/v1/chat — conversation réelle du noyau (J2) :
* LLM DeepSeek + outils MCP des apps
* GET /api/v1/conversations/… — historique de la conversation
* GET /api/v1/executors — registre (noyau, sous-agents J3,
* externes J4 : état de configuration)
* POST /api/v1/missions — missions longues (J5) : lancement,
* suivi, rapport, annulation
* États honnêtes : service injoignable / session non reconnue / LLM non
* configuré / exécuteur non configuré sont affichés tels quels. */
(function () {
"use strict";
var API = "https://agent.dracodev.net";
var convId = null;
try { convId = sessionStorage.getItem("draco_panneau_conv") || null; } catch (e) { /* stockage indispo */ }
var etatConnu = false;
var historiqueCharge = false;
var root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null;
function domaine() {
try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; }
}
function construire() {
root = document.createElement("aside");
root.className = "draco-panneau";
root.setAttribute("aria-label", "Spark, l'agent commun");
root.innerHTML =
'<div class="draco-panneau-hd">' +
' <span style="font-size:19px;color:#7c5cd6">✦</span>' +
' <div><div class="titre">Spark — agent commun</div>' +
' <div class="soustitre">Service d\'agents dracodev</div></div>' +
' <button type="button" class="fermer" aria-label="Fermer le panneau">✕</button>' +
'</div>' +
'<div class="draco-panneau-hd draco-panneau-etat" style="border-bottom:1px solid #e4e5e0;padding-top:8px;padding-bottom:8px">' +
' <span class="point"></span><span class="txt">Connexion au service…</span>' +
'</div>' +
'<div class="draco-panneau-outils">' +
' <button type="button" data-outil="aujourdhui">☀ Aujourd\'hui</button>' +
' <button type="button" data-outil="approbations">Approbations</button>' +
' <button type="button" data-outil="missions">🚀 Missions</button>' +
'</div>' +
'<div class="draco-panneau-msgs"></div>' +
'<div class="draco-panneau-ft"><form class="draco-panneau-form">' +
' <input type="text" placeholder="Écrire à Spark…" autocomplete="off" aria-label="Message à Spark">' +
' <button type="submit">Envoyer</button></form>' +
' <div class="draco-panneau-note">Conversation réelle, sous-agents (J3) et missions longues (J5) vers les exécuteurs configurés ; toute écriture dans les apps passe par une approbation.</div>' +
'</div>';
document.body.appendChild(root);
msgsEl = root.querySelector(".draco-panneau-msgs");
etatEl = root.querySelector(".draco-panneau-etat");
inputEl = root.querySelector("input");
sendBtn = root.querySelector("button[type=submit]");
root.querySelector(".fermer").addEventListener("click", fermer);
root.querySelector('[data-outil="aujourdhui"]').addEventListener("click", chargerAujourdhui);
root.querySelector('[data-outil="approbations"]').addEventListener("click", chargerApprobations);
root.querySelector('[data-outil="missions"]').addEventListener("click", ouvrirMissions);
root.querySelector("form").addEventListener("submit", envoyer);
document.addEventListener("keydown", function (ev) {
if (ev.key === "Escape" && root.classList.contains("open")) fermer();
});
ajouterMsg("sys", "Conversation avec le service d'agents — domaine : " + domaine() + ".");
}
function ajouterMsg(kind, texte) {
var d = document.createElement("div");
d.className = "draco-msg " + kind;
d.textContent = texte;
msgsEl.appendChild(d);
msgsEl.scrollTop = msgsEl.scrollHeight;
return d;
}
function majEtat(cls, texte) {
etatEl.className = "draco-panneau-hd draco-panneau-etat " + cls;
etatEl.querySelector(".txt").textContent = texte;
}
function verifierIdentite() {
if (etatConnu) return;
fetch(API + "/api/v1/me", { credentials: "include" })
.then(function (r) {
if (r.status === 401) {
majEtat("err", "Session partagée non reconnue");
ajouterMsg("sys", "Le service ne reconnaît pas ta session. Connecte-toi à ObsiGate (session unique draco_session), puis rouvre le panneau. Si le SSO n'est pas encore activé, c'est l'étape d'activation du programme.");
etatConnu = true;
return null;
}
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
})
.then(function (me) {
if (!me) return;
etatConnu = true;
majEtat("ok", "Connecté au service — " + (me.name || ("utilisateur " + me.sub)));
})
.catch(function () {
majEtat("err", "Service d'agents injoignable");
ajouterMsg("sys", "Le service d'agents (agent.dracodev.net) ne répond pas. Vérifie que le conteneur dracodev-agent est démarré.");
});
}
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>"]/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c];
});
}
function chargerAujourdhui() {
var bloc = document.createElement("div");
bloc.className = "draco-auj";
bloc.innerHTML = "<h5>Aujourd'hui — vu du service d'agents</h5><div class='etat-source'>Chargement…</div>";
msgsEl.appendChild(bloc); msgsEl.scrollTop = msgsEl.scrollHeight;
fetch(API + "/api/v1/aujourdhui", { credentials: "include" })
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
var h = "<h5>Aujourd'hui — " + esc(d.date) + " · domaine " + esc(d.domain) + "</h5>";
var t = d.sources.tiktak;
h += "<h5>Tâches du jour — TikTak</h5>";
if (t.status === "ok") {
if (!t.tasks.length) h += "<div class='etat-source'>Aucune tâche échue aujourd'hui. 🎉</div>";
else h += "<ul>" + t.tasks.map(function (x) { return "<li>" + esc(x.title) + "</li>"; }).join("") + "</ul>";
} else if (t.status === "non_configure") {
h += "<div class='etat-source'>Source non configurée : il manque la clé tck_ du service (voir le .env du conteneur agent).</div>";
} else if (t.status === "non_autorise") {
h += "<div class='etat-source'>Source non autorisée : la clé du service est refusée par TikTak (clé révoquée ?).</div>";
} else {
h += "<div class='etat-source'>Source TikTak " + esc(t.status) + ".</div>";
}
var k = d.sources.kanbante;
h += "<h5>Cartes en Révision — Kanbante</h5>";
if (k.status === "ok") {
if (!k.review.length) h += "<div class='etat-source'>Rien à valider. 🎉</div>";
else h += "<ul>" + k.review.map(function (x) { return "<li>#" + esc(x.number) + " — " + esc(x.title) + "</li>"; }).join("") + "</ul>";
} else if (k.status === "non_autorise") {
h += "<div class='etat-source'>Source non autorisée : Kanbante exige un jeton d'API pour le service — il arrive avec Kanbante 0.25.0, puis AGENT_MCP_KANBANTE_KEY dans le .env de l'agent.</div>";
} else {
h += "<div class='etat-source'>Source Kanbante " + esc(k.status) + ".</div>";
}
bloc.innerHTML = h;
msgsEl.scrollTop = msgsEl.scrollHeight;
})
.catch(function () {
bloc.innerHTML = "<h5>Aujourd'hui</h5><div class='etat-source'>Service injoignable.</div>";
});
}
function chargerApprobations() {
fetch(API + "/api/v1/approvals?status=pending", { credentials: "include" })
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
if (!d.approvals.length) { ajouterMsg("sys", "Aucune approbation en attente."); return; }
d.approvals.forEach(function (a) {
var bloc = document.createElement("div");
bloc.className = "draco-appro";
bloc.innerHTML = "<div class='titre'>Approbation demandée — " + esc(a.action) + "</div>" +
"<div class='resume'>" + esc(a.summary) + "</div>" +
"<div class='btns'><button type='button' class='go'>Approuver</button>" +
"<button type='button' class='non'>Rejeter</button></div>";
bloc.querySelector(".go").addEventListener("click", function () { decider(a.id, "approved", bloc); });
bloc.querySelector(".non").addEventListener("click", function () { decider(a.id, "rejected", bloc); });
msgsEl.appendChild(bloc);
});
msgsEl.scrollTop = msgsEl.scrollHeight;
})
.catch(function () { ajouterMsg("sys", "Approbations : service injoignable."); });
}
function decider(id, decision, bloc) {
fetch(API + "/api/v1/approvals/" + id + "/decide", {
method: "POST", credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ decision: decision })
})
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
bloc.style.opacity = ".55";
bloc.querySelector(".btns").innerHTML = "<span style='font-size:12.5px;color:#6c7268'>Décision enregistrée : " + esc(d.status) + "</span>";
})
.catch(function () { ajouterMsg("sys", "La décision n'a pas pu être envoyée (service injoignable ?)."); });
}
/* ── Missions longues (J5) ───────────────────────────────────
* Sélecteur d'exécuteur alimenté par le registre du service :
* les externes non configurés sont montrés mais désactivés —
* jamais un lancement qui simule. La liste se rafraîchit à
* l'ouverture et après chaque lancement/annulation. */
var STATUTS_MISSION = {
queued: "En file", running: "En cours…", paused_question: "Question en attente",
succeeded: "Terminée", failed: "Échouée", cancelled: "Annulée"
};
function ouvrirMissions() {
var bloc = document.createElement("div");
bloc.className = "draco-missions";
bloc.innerHTML =
"<h5>Missions longues</h5>" +
"<label>Exécuteur</label><select class='m-exec'><option>Chargement…</option></select>" +
"<label>Titre</label><input type='text' class='m-titre' placeholder='Ex. Veille des projets du matin'>" +
"<label>Consigne</label><textarea class='m-prompt' rows='3' placeholder='Ce que l\u2019exécuteur doit faire…'></textarea>" +
"<label>Carte Kanbante liée (n°, optionnel)</label><input type='number' class='m-carte' min='1' placeholder='—'>" +
"<div class='btns'><button type='button' class='go'>Lancer la mission</button>" +
"<button type='button' class='rafraichir'>Rafraîchir la liste</button></div>" +
"<div class='m-liste'><div class='etat-source'>Chargement des missions…</div></div>";
msgsEl.appendChild(bloc);
msgsEl.scrollTop = msgsEl.scrollHeight;
fetch(API + "/api/v1/executors", { credentials: "include" })
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
var sel = bloc.querySelector(".m-exec");
sel.innerHTML = "";
d.executors.forEach(function (e) {
if (e.family !== "sous-agent" && e.family !== "externe") return;
var opt = document.createElement("option");
opt.value = e.id;
opt.textContent = e.name + (e.family === "sous-agent" ? " (sous-agent)" : "") +
(e.status === "non_configure" ? " — non configuré" : "");
if (e.status === "non_configure") opt.disabled = true;
sel.appendChild(opt);
});
if (!sel.options.length) sel.innerHTML = "<option value=''>Aucun exécuteur lançable</option>";
})
.catch(function () { bloc.querySelector(".m-exec").innerHTML = "<option value=''>Registre injoignable</option>"; });
bloc.querySelector(".go").addEventListener("click", function () { lancerMission(bloc); });
bloc.querySelector(".rafraichir").addEventListener("click", function () { chargerMissions(bloc); });
chargerMissions(bloc);
}
function chargerMissions(bloc) {
var zone = bloc.querySelector(".m-liste");
fetch(API + "/api/v1/missions", { credentials: "include" })
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
if (!d.missions.length) { zone.innerHTML = "<div class='etat-source'>Aucune mission pour le moment.</div>"; return; }
zone.innerHTML = "";
d.missions.slice(0, 10).forEach(function (m) {
var ligne = document.createElement("div");
ligne.className = "m-item";
var rapport = m.result ? "<div class='m-rapport'>" + esc(String(m.result).slice(0, 700)) + "</div>" : "";
ligne.innerHTML = "<div class='m-entete'><b>" + esc(m.title) + "</b>" +
"<span class='m-statut st-" + esc(m.status) + "'>" + esc(STATUTS_MISSION[m.status] || m.status) + "</span></div>" +
"<div class='m-meta'>" + esc(m.executor) +
(m.meta && m.meta.duree_s ? " · " + esc(m.meta.duree_s) + " s" : "") +
(m.meta && m.meta.cout ? " · coût : " + esc(m.meta.cout) : "") + "</div>" + rapport;
if (m.status === "queued" || m.status === "running" || m.status === "paused_question") {
var btn = document.createElement("button");
btn.type = "button"; btn.className = "m-annuler"; btn.textContent = "Annuler";
btn.addEventListener("click", function () {
fetch(API + "/api/v1/missions/" + m.id + "/cancel", {
method: "POST", credentials: "include",
headers: { "Content-Type": "application/json" }, body: "{}"
}).then(function () { chargerMissions(bloc); })
.catch(function () { ajouterMsg("sys", "Annulation non envoyée (service injoignable ?)."); });
});
ligne.appendChild(btn);
}
zone.appendChild(ligne);
});
})
.catch(function () { zone.innerHTML = "<div class='etat-source'>Missions : service injoignable.</div>"; });
}
function lancerMission(bloc) {
var executor = bloc.querySelector(".m-exec").value;
var titre = bloc.querySelector(".m-titre").value.trim();
var prompt = bloc.querySelector(".m-prompt").value.trim();
var carte = bloc.querySelector(".m-carte").value;
if (!executor) { ajouterMsg("sys", "Choisis un exécuteur configuré pour lancer une mission."); return; }
if (!titre || !prompt) { ajouterMsg("sys", "Mission : il faut un titre et une consigne."); return; }
var corps = { executor: executor, title: titre, prompt: prompt, domain: domaine() };
if (carte) corps.kanbante_number = parseInt(carte, 10);
fetch(API + "/api/v1/missions", {
method: "POST", credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(corps)
})
.then(function (r) {
if (!r.ok) return r.json().catch(function () { return null; }).then(function (e) {
throw new Error(e && e.detail ? e.detail : "HTTP " + r.status);
});
return r.json();
})
.then(function () {
ajouterMsg("sys", "Mission « " + titre + " » lancée — suis-la dans la liste des missions.");
chargerMissions(bloc);
})
.catch(function (e) { ajouterMsg("sys", "Lancement refusé : " + e.message); });
}
function chargerHistorique() {
if (historiqueCharge || !convId) return;
historiqueCharge = true;
fetch(API + "/api/v1/conversations/" + convId, { credentials: "include" })
.then(function (r) {
if (r.status === 404) {
convId = null;
try { sessionStorage.removeItem("draco_panneau_conv"); } catch (e) { /* ok */ }
return null;
}
if (!r.ok) return null;
return r.json();
})
.then(function (d) {
if (!d || !d.messages || !d.messages.length) return;
var sys = msgsEl.querySelector(".draco-msg.sys");
d.messages.forEach(function (m) {
var el = ajouterMsg(m.role === "user" ? "user" : "agent", m.content);
if (sys) msgsEl.insertBefore(el, sys.nextSibling), sys = el;
});
msgsEl.scrollTop = msgsEl.scrollHeight;
})
.catch(function () { /* historique indisponible : la conversation repart à neuf */ });
}
function envoyer(ev) {
ev.preventDefault();
var texte = inputEl.value.trim();
if (!texte) return;
inputEl.value = "";
ajouterMsg("user", texte);
sendBtn.disabled = true;
var attente = ajouterMsg("agent", "Spark réfléchit…");
fetch(API + "/api/v1/chat", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: texte, domain: domaine(), conversation_id: convId })
})
.then(function (r) {
if (r.status === 401) {
attente.textContent = "Le service ne reconnaît pas ta session partagée (401). Connecte-toi à ObsiGate puis réessaie.";
majEtat("err", "Session partagée non reconnue");
return null;
}
if (!r.ok) {
return r.json().catch(function () { return null; }).then(function (e) {
attente.textContent = (e && e.detail) ? e.detail :
"Le service a répondu HTTP " + r.status + " — réessaie dans un instant.";
return null;
});
}
return r.json();
})
.then(function (d) {
if (!d) return;
convId = d.conversation_id;
try { sessionStorage.setItem("draco_panneau_conv", convId); } catch (e) { /* ok */ }
attente.textContent = d.reply;
if (d.tools_used && d.tools_used.length) {
ajouterMsg("sys", "Outils utilisés : " + d.tools_used.join(", ") + " · modèle : " + (d.model || "?"));
}
majEtat("ok", "Connecté au service — domaine " + d.domain);
})
.catch(function () {
attente.textContent = "Service injoignable. Vérifie que le conteneur dracodev-agent est démarré, puis réessaie.";
majEtat("err", "Service d'agents injoignable");
})
.finally(function () { sendBtn.disabled = false; inputEl.focus(); });
}
function ouvrir() {
if (!root) construire();
root.classList.add("open");
verifierIdentite();
chargerHistorique();
setTimeout(function () { inputEl.focus(); }, 300);
}
function fermer() { if (root) root.classList.remove("open"); }
window.DRACO_PANNEAU = {
open: ouvrir,
close: fermer,
toggle: function () {
if (root && root.classList.contains("open")) fermer(); else ouvrir();
}
};
})();
+71
View File
@@ -0,0 +1,71 @@
"""Jetons d'API personnels (0.25.0) : modèle + accès MCP par Bearer.
Le service d'agents se fait refuser par Kanbante depuis la 0.24.0
(auth partout, cookie/Basic seulement). Les jetons kbt_ lui donnent
une identité machine propre, révocable, limitée à /mcp et /api/.
"""
import pytest
from fastapi.testclient import TestClient
import app.main as main
from app import accounts, models
@pytest.fixture()
def utilisateur():
# La base de test est partagée par toute la session : nom unique,
# et nettoyage complet au teardown — le pont SSO (test_draco_sso)
# a une sémantique « un seul compte en base » dont hérite la suite.
import uuid
uid = models.create_user(
f"machine-{uuid.uuid4().hex[:8]}",
accounts.hash_password("motdepasse"), role="member")
user = models.get_user(uid)
yield user
with models.get_db() as conn:
conn.execute("DELETE FROM api_tokens WHERE user_id = ?", (uid,))
conn.execute("DELETE FROM users WHERE id = ?", (uid,))
def test_jeton_cycle_de_vie(utilisateur):
row, clair = models.create_api_token(utilisateur["id"], "service-agents")
assert clair.startswith("kbt_")
# La base ne connaît que l'empreinte, jamais le texte clair.
assert row["token_hash"] != clair and len(row["token_hash"]) == 64
trouve = models.user_by_api_token(clair)
assert trouve is not None and trouve["username"] == utilisateur["username"]
assert models.list_api_tokens(utilisateur["id"])[0]["last_used_at"] is not None
# Révocation : effet immédiat.
assert models.revoke_api_token(utilisateur["id"], row["id"]) is True
assert models.user_by_api_token(clair) is None
# On ne révoque pas le jeton d'autrui.
row2, _clair2 = models.create_api_token(utilisateur["id"], "autre")
assert models.revoke_api_token(utilisateur["id"] + 999, row2["id"]) is False
def test_jeton_invalide_ou_compte_desactive(utilisateur):
_row, clair = models.create_api_token(utilisateur["id"], "x")
assert models.user_by_api_token("kbt_nimportequoi") is None
assert models.user_by_api_token("") is None
assert models.user_by_api_token("tck_autreformat") is None
models.update_user(utilisateur["id"], active=0)
assert models.user_by_api_token(clair) is None
def test_mcp_par_jeton_quand_auth_active(monkeypatch, utilisateur):
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
_row, clair = models.create_api_token(utilisateur["id"], "service-agents")
corps = {"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": "2024-11-05"}}
with TestClient(main.app) as client:
# Sans jeton : 401 (le refus vu en production le 2026-10-21).
assert client.post("/mcp", json=corps).status_code == 401
# Avec le jeton Bearer : le handshake passe.
r = client.post("/mcp", json=corps,
headers={"Authorization": f"Bearer {clair}"})
assert r.status_code == 200
assert r.json()["result"]["serverInfo"]["name"] == "kanbante"
# Mais un jeton n'ouvre pas l'interface web.
assert client.get("/", headers={"Authorization": f"Bearer {clair}",
"Accept": "application/json"}).status_code == 401
+202
View File
@@ -0,0 +1,202 @@
"""Écritures MCP minimales (0.26.0) : create_issue + add_comment.
Le service d'agents doit pouvoir proposer des cartes et commenter
via Kanbante, sans passer par l'interface web. Les deux outils
réutilisent les services existants (création via _do_capture →
client de forge, commentaire via client.create_comment) et sont
gardés par la même authentification que les lectures /mcp :
session, Basic ou jeton d'API kbt_ — jamais l'interface web.
"""
import json
import uuid
import pytest
from fastapi.testclient import TestClient
import app.main as main
from app import accounts, models
def _call(client, name, arguments, headers=None):
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {"name": name, "arguments": arguments},
}
return client.post("/mcp", json=payload, headers=headers or {})
def _result_text(response):
result = response.json()["result"]
return result, result["content"][0]["text"]
# ── Création de carte ──────────────────────────────────────
def test_mcp_create_issue_places_card(client, fake_gitea):
r = _call(client, "create_issue", {
"owner": "Projets", "repo": "demo",
"title": "Carte créée par MCP", "body": "Corps de la carte",
})
result, text = _result_text(r)
assert result["isError"] is False
created = json.loads(text)
assert created["title"] == "Carte créée par MCP"
assert created["repo_full_name"] == "Projets/demo"
assert created["column"] == "backlog"
assert created["number"] > 0
# L'écriture est passée par le client de forge existant.
assert fake_gitea.created == [("Projets", "demo", "Carte créée par MCP")]
# La carte est placée sur le board (lecture MCP relue).
r = _call(client, "get_board", {"owner": "Projets", "repo": "demo"})
board = json.loads(_result_text(r)[1])
assert any(
c["title"] == "Carte créée par MCP" for c in board["backlog"]
)
# Journal : création tracée avec la source « mcp ».
events = models.get_events(repo_full_name="Projets/demo", limit=10)
created_events = [
e for e in events
if e["event_type"] == "created"
and e["issue_number"] == created["number"]
]
assert created_events and created_events[0]["source"] == "mcp"
def test_mcp_create_issue_column_choice(client, fake_gitea):
r = _call(client, "create_issue", {
"owner": "Projets", "repo": "demo",
"title": "Directement à faire", "column": "todo",
})
result, text = _result_text(r)
assert result["isError"] is False
assert json.loads(text)["column"] == "todo"
def test_mcp_create_issue_validation_errors(client, fake_gitea):
# Titre manquant : « Missing argument » (patron d'erreur du serveur).
r = _call(client, "create_issue", {"owner": "Projets", "repo": "demo"})
result, text = _result_text(r)
assert result["isError"] is True
assert "Missing argument" in text
# Titre vide et colonne inconnue : erreurs métier en isError.
for arguments in (
{"owner": "Projets", "repo": "demo", "title": " "},
{"owner": "Projets", "repo": "demo", "title": "X",
"column": "nimporte"},
):
result, _text = _result_text(_call(client, "create_issue", arguments))
assert result["isError"] is True
assert fake_gitea.created == []
# ── Commentaire ────────────────────────────────────────────
def test_mcp_add_comment_posts_on_forge(client, fake_gitea):
r = _call(client, "add_comment", {
"owner": "Projets", "repo": "demo", "number": 1,
"body": "Commentaire du service d'agents",
})
result, text = _result_text(r)
assert result["isError"] is False
payload = json.loads(text)
assert payload["number"] == 1
assert payload["repo_full_name"] == "Projets/demo"
assert payload["body"] == "Commentaire du service d'agents"
assert fake_gitea.posted_comments == [
("Projets", "demo", 1, "Commentaire du service d'agents")
]
events = models.get_events(repo_full_name="Projets/demo", limit=10)
commented = [e for e in events if e["event_type"] == "commented"]
assert commented and commented[0]["source"] == "mcp"
def test_mcp_add_comment_validation_errors(client, fake_gitea):
r = _call(client, "add_comment", {
"owner": "Projets", "repo": "demo", "number": 1, "body": " ",
})
assert _result_text(r)[0]["isError"] is True
r = _call(client, "add_comment", {
"owner": "Projets", "repo": "demo", "body": "sans numéro",
})
result, text = _result_text(r)
assert result["isError"] is True
assert "Missing argument" in text
assert fake_gitea.posted_comments == []
def test_mcp_write_tools_are_listed(client):
r = client.post("/mcp", json={
"jsonrpc": "2.0", "id": 1, "method": "tools/list",
})
tools = {t["name"]: t for t in r.json()["result"]["tools"]}
assert tools["create_issue"]["inputSchema"]["required"] == [
"owner", "repo", "title",
]
assert tools["add_comment"]["inputSchema"]["required"] == [
"owner", "repo", "number", "body",
]
# Aucun outil destructeur n'apparaît.
assert not any(
name.startswith(("delete", "remove", "close"))
for name in tools
)
# ── Garde par jeton kbt_ (auth active) ─────────────────────
@pytest.fixture()
def utilisateur():
# Base de test partagée : nom unique, nettoyage complet au
# teardown (le pont SSO a une sémantique « un seul compte »).
uid = models.create_user(
f"mcp-{uuid.uuid4().hex[:8]}",
accounts.hash_password("motdepasse"), role="member")
user = models.get_user(uid)
yield user
with models.get_db() as conn:
conn.execute("DELETE FROM api_tokens WHERE user_id = ?", (uid,))
conn.execute("DELETE FROM users WHERE id = ?", (uid,))
def test_mcp_writes_guarded_by_api_token(monkeypatch, fake_gitea, utilisateur):
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
_row, clair = models.create_api_token(utilisateur["id"], "service-agents")
jeton = {"Authorization": f"Bearer {clair}"}
create_args = {
"owner": "Projets", "repo": "demo", "title": "Carte par jeton",
}
with TestClient(main.app) as client:
# Sans jeton : refusé (401), aucune écriture sur la forge.
assert _call(client, "create_issue", create_args).status_code == 401
# Jeton inconnu : refusé aussi.
faux = {"Authorization": "Bearer kbt_inconnu"}
assert _call(
client, "create_issue", create_args, headers=faux
).status_code == 401
assert fake_gitea.created == []
# Jeton valide : création et commentaire passent.
r = _call(client, "create_issue", create_args, headers=jeton)
assert r.status_code == 200
assert _result_text(r)[0]["isError"] is False
assert fake_gitea.created == [("Projets", "demo", "Carte par jeton")]
r = _call(client, "add_comment", {
"owner": "Projets", "repo": "demo", "number": 1,
"body": "Vu par le service d'agents",
}, headers=jeton)
assert _result_text(r)[0]["isError"] is False
# Le même jeton n'ouvre jamais l'interface web.
assert client.get(
"/", headers={**jeton, "Accept": "application/json"}
).status_code == 401
# Révocation : effet immédiat sur les écritures.
assert models.revoke_api_token(utilisateur["id"], _row["id"]) is True
assert _call(
client, "create_issue", create_args, headers=jeton
).status_code == 401
assert _call(client, "add_comment", {
"owner": "Projets", "repo": "demo", "number": 1, "body": "x",
}, headers=jeton).status_code == 401
+6 -2
View File
@@ -121,6 +121,7 @@ def test_mcp_initialize_and_tools_list(client):
assert names == [
"list_projects", "get_board", "get_issue", "get_focus",
"search_issues", "get_activity", "get_metrics",
"create_issue", "add_comment",
]
@@ -153,7 +154,7 @@ def test_mcp_unknown_method_and_tool(client):
assert r.json()["error"]["code"] == -32601
r = _rpc(client, "tools/call", {"name": "delete_everything", "arguments": {}})
result = r.json()["result"]
assert result["isError"] is True # read-only server: no such tool
assert result["isError"] is True # aucun outil de ce nom (pas de suppression)
def test_mcp_notification_gets_202(client):
@@ -164,7 +165,10 @@ def test_mcp_notification_gets_202(client):
def test_mcp_info_endpoint(client):
r = client.get("/mcp")
assert r.status_code == 200
assert r.json()["read_only"] is True
# 0.26.0 : écritures minimales ajoutées — le serveur n'est
# plus en lecture seule, et il le déclare honnêtement.
assert r.json()["read_only"] is False
assert r.json()["write_tools"] == ["create_issue", "add_comment"]
# ── Board : gestion des étiquettes ────────────────────────
+20
View File
@@ -0,0 +1,20 @@
"""0.24.2 — #29 : base sombre pour tous les champs de saisie.
Sans règle de fond, les champs hors contexte stylé gardaient le
fond blanc du navigateur (réinitialisation de mot de passe,
textarea des espaces, valeurs du panneau des paramètres).
"""
from pathlib import Path
R = Path(__file__).resolve().parents[1]
def test_base_champs_sombre():
css = (R / "static/css/style.css").read_text(encoding="utf-8")
assert "color-scheme: dark" in css
assert ":where(input:not([type=\"checkbox\"])" in css
assert "textarea, select" in css
assert "background: var(--surface-2);" in css
# la base est en spécificité zéro : les contextes restent maîtres
assert ".palette-input-row input" in css
assert ".login-form input" in css