feat(web): add local application on 8081 with session recovery

This commit is contained in:
fy59 2026-09-27 13:54:03 +02:00
parent d65f9559e6
commit 3488f67b2a
28 changed files with 3197 additions and 193 deletions

View file

@ -1,5 +1,24 @@
## [0.1.0-dev] — en préparation, non publiée
### Connexion Web
- La session de `3600` secondes (une heure) commence à l'authentification, et non au démarrage
du serveur ; une reconnexion après expiration renouvelle cookie et CSRF.
- Régressions HTTP synthétiques : première connexion tardive, expiration
absolue, ancien cookie refusé et échec de connexion sans prolongation.
- Le parcours Firefox synthétique couvre désormais l'expiration pendant les
pollings, l'arrêt unique des requêtes, la reconnexion sur le même serveur et
le refus du couple cookie/CSRF précédent. L'interface ne rejoue aucune
mutation refusée ou d'admission inconnue.
### Validation Web
- Le runner navigateur comptabilise `MISSING`, `LAUNCH_ERROR`, `EXIT_CODE`,
`SIGNALED`, `TIMEOUT`, `INTERRUPTED` et `SUCCESS`. Sur Linux, il ne termine
que le groupe et la session de processus qu'il a créés : `SIGTERM`, grâce
bornée, puis `SIGKILL` si nécessaire. Ses régressions synthétiques vérifient
qu'un témoin indépendant survit.
### Pivots locaux J7
- Index C typé des observations persistées, rapprochements explicables
@ -167,6 +186,17 @@
# Changements locaux non publiés — poste Web UX V1
## Poste Web local et bibliothèque (non publié)
- Lanceur local à instance unique, bibliothèque explicitement choisie,
création C idempotente et ouverture d'une seule enquête active par instance.
- État d'instance, configuration et code d'amorçage séparés dans les répertoires
XDG privés ; écoute loopback et contrôles de santé avant `status`, `stop` ou
consultation du code.
- Validation Web intégrée : bridges C, unités Node, découverte Python et
parcours navigateur exécutés séquentiellement par
`make web-workspace-web-check`.
- Réorganisation graph-first en barre compacte, projections, inspecteur à
onglets et tiroir Tâches/Actions/Plans/Rapports responsive.
- Ajout d’actions ancrées près des nœuds, avec clic droit, `Maj+F10`, fermeture

View file

@ -17,8 +17,30 @@ SOURCE_SIZE_EXCEPTIONS := \
src/views/main_window.c:2239 \
src/views/create_relation_dialog.c:2043
.PHONY: check-source-size web-workspace web-workspace-j7 web-workspace-j8 web-workspace-j9 web-workspace-local web-workspace-review-check
.PHONY: check-source-size web web-start web-status web-stop web-code web-workspace web-workspace-j7 web-workspace-j8 web-workspace-j9 web-workspace-local web-workspace-review-check web-workspace-web-check
.NOTPARALLEL: web-workspace-review-check
# CONTRACT: le poste Web possède sa bibliothèque explicite, séparée des
# enquêtes existantes. XDG_DATA_HOME est évalué par Python afin que Make ne
# propage pas un chemin personnel figé dans le dépôt.
PORT ?= 8081
LIBRARY ?= $(shell python3 -c 'import os; from pathlib import Path; print(Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) / "labfy-investigation" / "web-8081-lab")')
web: tools/local-jobs
tools/labfy-web serve --port "$(PORT)" --library "$(LIBRARY)"
web-start: tools/local-jobs
tools/labfy-web start --port "$(PORT)" --library "$(LIBRARY)"
web-status:
tools/labfy-web status
web-stop:
tools/labfy-web stop
web-code:
tools/labfy-web code
check-source-size:
@limit=$(SOURCE_SIZE_LIMIT); failed=0; \
files="$$(git ls-files --cached --others --exclude-standard -- \
@ -72,6 +94,15 @@ web-workspace-review-check: tools/local-jobs tools/local-jobs-test
@echo "Phase 2/2 — parcours navigateur de l'espace de travail local"
node prototypes/web-graph/tests/test_local_workspace_import_browser.mjs
# CONTRACT: construit les bridges C avant les validations Web qui les invoquent.
web-workspace-web-check: $(LOCAL_JOBS) $(LOCAL_JOBS_TEST) $(CORE_GRAPH_DEMO) $(EML_GRAPH_DEMO) $(LOCAL_TOOLKIT_DEMO)
@echo "Phase 1/3 — unité Node du graphe Web"
cd prototypes/web-graph && npm test
@echo "Phase 2/3 — découverte Python du Web"
python3 -m unittest discover -s prototypes/web-graph/tests -p 'test_*.py'
@echo "Phase 3/3 — parcours navigateur Web séquentiels"
cd prototypes/web-graph && npm run test:browser
CFLAGS = -std=c17 \
-Wall \
-Wextra \

View file

@ -10,10 +10,11 @@ Le premier lot J7 ajoute un index C recalculable et des rapprochements locaux
explicables pour les e-mails, domaines et IP observés :
[pivots locaux et corrélation](docs/architecture/LOCAL_PIVOTS_CORRELATION.md).
Le [poste Web local](docs/ui/WEB_WORKBENCH.md) rassemble désormais le graphe,
l’inspecteur contextuel, les tâches, le planner J8 et les rapports J9 dans une
surface `SPECIMEN` responsive. Cette disponibilité locale ne change ni le
statut de production, ni l’interdiction d’utiliser une enquête réelle.
Le [poste Web local](docs/ui/WEB_WORKBENCH.md) rassemble désormais une
bibliothèque d’enquêtes explicitement choisie, le graphe, l’inspecteur
contextuel, les tâches, le planner J8 et les rapports J9. Cette disponibilité
locale ne change ni le statut de production, ni l’interdiction d’utiliser une
enquête réelle pendant les validations de développement.
Le parcours local expérimental d’import et de revue est détaillé dans
[le contrat d’espace local](docs/architecture/LOCAL_WORKSPACE_IMPORT.md) :
@ -66,10 +67,10 @@ données. Labfy vise à relier leurs résultats dans une enquête cohérente :
La cible de développement est **v0.1.0**. Le projet reste en développement actif
et n'est pas prêt pour un usage opérationnel en production.
**Interface GTK conservée ; poste Web local `SPECIMEN` disponible.** La
fondation v0.1.0 reste en développement : le poste Web réutilise le moteur de
jobs persistant existant, sans constituer une interface de production ni une
autonomie générale.
**Interface GTK conservée ; poste Web local contrôlé disponible.** La fondation
v0.1.0 reste en développement : le poste Web réutilise le moteur de jobs
persistant existant et une bibliothèque locale explicitement choisie, sans
constituer une interface de production ni une autonomie générale.
### Disponible aujourd'hui
@ -295,6 +296,19 @@ python3 run_core_demo.py --port 8765
Le projet exige C17, GTK4, GLib/GIO, SQLite, libheif et Poppler GLib. Plusieurs
outils d'analyse sont optionnels et ne sont jamais installés automatiquement.
Le poste Web local se lance depuis une bibliothèque explicitement désignée ;
il écoute par défaut sur `127.0.0.1:8081` :
```bash
make web PORT=8081 LIBRARY=/chemin/vers/la-bibliotheque-locale
```
Les commandes `start`, `status`, `stop` et `code` de ce lanceur, ainsi que les
contrats de stockage local et de session, sont décrits dans
[le contrôle du poste Web](docs/architecture/WEB_WORKSPACE_CONTROL.md). Le
code éphémère affiché au démarrage reste privé : il ne doit ni être ajouté à une
commande, ni enregistré dans la documentation.
## Documentation
- [Index documentaire](docs/README.md)

View file

@ -169,6 +169,11 @@ Le socle comprend déjà :
extraction et observations `proposed` sont publiés atomiquement en V20, puis
projetés en snapshot v3 sans reconstruction dans le pont Web ; voir
[EML_ANALYSIS_TO_GRAPH.md](architecture/EML_ANALYSIS_TO_GRAPH.md).
- poste Web local contrôlé : une instance loopback possède une bibliothèque
explicitement choisie et ne rend active qu'une enquête à la fois. Le bridge
C reste le créateur des enquêtes, le lecteur des projections et l'unique
accès aux stockages métier ; voir
[WEB_WORKSPACE_CONTROL.md](architecture/WEB_WORKSPACE_CONTROL.md).
Ces composants doivent être réutilisés ou migrés. La direction V2 n'autorise
pas une réécriture générale du cœur fonctionnel.
@ -591,9 +596,20 @@ analyse approuvée et rapport.
## 17. Espace local, revue et projections — CURRENT expérimental
Le parcours local est limité à une instance loopback et à des validations sur
fixtures `SPECIMEN`. Il ne change pas le statut de la surface Web : GTK reste
le parcours fonctionnel actuel et le serveur, son packaging ainsi que le choix
du renderer de production restent `UNDECIDED`.
fixtures `SPECIMEN`. Son lanceur peut ouvrir une bibliothèque locale
explicitement choisie, en créer les enquêtes par le bridge C et n'en active
qu'une seule dans une instance. Il ne rouvre pas implicitement l'enquête d'une
session précédente. Cette surface ne change pas son statut : GTK reste le
parcours fonctionnel actuel et le serveur, son packaging ainsi que le choix du
renderer de production restent `UNDECIDED`.
Le runtime métier demeure V20 et le JobStore opérationnel demeure V3 ; le
poste Web ne migre ni ne modifie la tranche financière V21. L'état d'instance
et le secret d'amorçage sont séparés dans le runtime privé XDG (avec un repli
privé propre à l'UID lorsque `XDG_RUNTIME_DIR` est absent) ; la configuration
durable est placée dans l'état XDG privé. La bibliothèque est une destination
locale fournie explicitement au lancement : elle n'est pas déduite d'une
dernière enquête ouverte.
Après la publication d'une preuve V20, le bridge Web demande au cœur C un
aperçu EML, PNG ou JPEG. L'original, son chemin et son répertoire ne sont jamais

View file

@ -131,3 +131,10 @@ vérifiée avec `command -v`, puis avec son option de version (`--version` ou
L’import d’espace local n’ajoute aucune dépendance : le transport borné utilise
la bibliothèque HTTP Python existante, et la publication/empreinte réutilise le
cœur C/GLib. Firefox et `puppeteer-core` restent nécessaires au scénario Web.
Le poste Web local requiert également l'interpréteur `python3` déjà utilisé par
les lanceurs du prototype. Il ne requiert ni serveur HTTP système, ni base de
données distincte, ni installation automatique. Ses fichiers d'instance et de
configuration suivent XDG (`XDG_RUNTIME_DIR`, `XDG_STATE_HOME`) ; la
bibliothèque d'enquêtes est une destination locale explicitement fournie par
`LIBRARY`, et non une dépendance à installer.

View file

@ -16,8 +16,12 @@ déjà disponible.
- [architecture de la base](database/DATABASE_ARCHITECTURE.md) et
[audit courant du schéma](database/SCHEMA_AUDIT_CURRENT.md) ;
- [test manuel du pivot EML](testing/EML_PIVOT_MANUAL_TEST.md).
- [poste Web local contrôlé](architecture/WEB_WORKSPACE_CONTROL.md) et
[guide du poste Web](ui/WEB_WORKBENCH.md).
Les guides GTK restent applicables tant que l'interface actuelle est maintenue.
Le poste Web local `CURRENT` ne retire donc aucun parcours GTK ni ne décide le
serveur, le renderer ou le packaging de production.
## Direction v0.1.0 — TARGET

View file

@ -1,24 +1,77 @@
# Poste de travail Web contrôlé — J6 local
> État : `CURRENT` pour le seul espace `SPECIMEN` produit par le lanceur J6.
> Runtime métier : V20 inchangé. JobStore : V1 inchangé. V21 reste indépendante.
> État : `CURRENT` pour le poste Web local contrôlé et sa bibliothèque
> explicitement choisie. Runtime métier : V20 inchangé. JobStore : V3 inchangé.
> V21 reste indépendante.
## Parcours
Une commande prépare ou reprend un espace synthétique, lance l’API loopback et
son worker possédé, puis affiche l’URL et un code de session éphémère :
La commande principale lance l’API loopback sur une bibliothèque locale
explicitement choisie. Elle affiche l’URL et un code de session éphémère :
```sh
make -j8 tools/local-jobs
make web-workspace WORKSPACE=/tmp/labfy-j6-specimen
make web PORT=8081 LIBRARY=/chemin/vers/la-bibliotheque-locale
```
L’utilisateur ouvre l’URL, saisit le code, sélectionne l’EML ou l’image dans le
graphe et choisit « Analyser les en-têtes » ou « Examiner les métadonnées ».
L’intention est persistée avant la réponse HTTP `202`. Le worker C exécute le
service J3/J4 correspondant, puis C republie atomiquement les snapshots jobs et
métier. Le navigateur les relit sans rechargement de page. Pause, reprise, stop
et annulation sont disponibles dans le panneau Jobs.
L'instance écoute sur `127.0.0.1:8081` dans cet exemple. Après authentification,
la bibliothèque permet de créer une enquête locale par le bridge C ou d'ouvrir
une enquête enregistrée. Une seule enquête peut être active par instance ; une
autre ouverture exige le redémarrage du poste local. L'utilisateur sélectionne
ensuite l’EML ou l’image dans le graphe et choisit « Analyser les en-têtes » ou
« Examiner les métadonnées ». L’intention est persistée avant la réponse HTTP
`202`. Le worker C exécute le service J3/J4 correspondant, puis C republie
atomiquement les snapshots jobs et métier. Le navigateur les relit sans
rechargement de page. Pause, reprise, stop et annulation sont disponibles dans
le panneau Jobs.
Le lanceur gère une seule instance authentifiée. Ses sous-commandes sont :
```sh
python3 prototypes/web-graph/web_app.py start --library /chemin/vers/la-bibliotheque-locale --port 8081
python3 prototypes/web-graph/web_app.py status
python3 prototypes/web-graph/web_app.py stop
python3 prototypes/web-graph/web_app.py code
```
`start` refuse une seconde instance active. `status`, `stop` et `code` valident
l'identité du processus et l'état de santé loopback avant d'agir ou de révéler
le code éphémère. Ne pas consigner ce code dans des scripts, URLs, journaux ou
documents. Le mode `--workspace` reste le parcours ciblé existant ; il ne crée
pas une bibliothèque implicite.
## Bibliothèque, état XDG et limites
La bibliothèque est privée (`0700`), refuse les liens symboliques et conserve
un registre atomique borné à 256 enquêtes. Chaque enquête est un enfant direct
identifié par UUID ; le bridge C existant est le seul créateur de son runtime
V20 et de son JobStore V3. La création est idempotente pour une même clé et le
même titre ; réemployer la clé avec un autre titre est refusé. L'ouverture exige
la génération courante du registre afin de ne pas utiliser une projection
périmée.
L'instance place ses verrous, identité et code d'amorçage dans
`$XDG_RUNTIME_DIR/labfy-investigation-web/`, ou dans un repli privé par UID
sous `/tmp` si `XDG_RUNTIME_DIR` n'est pas défini. Sa configuration durable est
dans `$XDG_STATE_HOME/labfy-investigation-web/` (ou le repli XDG usuel). Ces
fichiers ne sont ni des données métier, ni une bibliothèque : la destination
des enquêtes reste l'argument explicite `LIBRARY`.
La session a une durée maximale de `3600` secondes (une heure) à compter de chaque connexion
réussie, indépendamment de l'heure de démarrage du serveur. Après expiration,
le code de l'instance permet une nouvelle authentification : les secrets de
session et CSRF sont renouvelés et l'ancien cookie reste invalide. Un code
incorrect ou une origine refusée ne renouvelle jamais cette durée.
Le premier refus `session_required` reçu par le navigateur arrête ses flux et
pollings, invalide les secrets seulement en mémoire et présente une reconnexion
explicite. Il ne redirige pas en boucle et ne rejoue ni une mutation refusée ni
une admission dont l'issue est inconnue. Les brouillons autorisés restent dans
la session du navigateur, tandis que les jobs déjà admis conservent leurs
budgets et leur cycle de vie propres.
Les limites HTTP et opérationnelles
restent opposables : corps JSON de commande de 4 Kio, huit fichiers sélectionnés
pour 16 Mio, 4 Mio par réception, 64 Mio de staging, deux réceptions actives et
deux générations de rapport simultanées. Une limite, une annulation ou une
erreur d'export est un état explicite et ne prouve jamais l'absence de résultat.
## Décision HTTP et bridge
@ -55,7 +108,7 @@ Le serveur écoute exclusivement sur l’autorité annoncée
`127.0.0.1:<port>`. Le code d’amorçage et les secrets de session proviennent de
`secrets`, ne sont pas placés dans une URL et ne sont pas journalisés par les
routes. Après amorçage, le cookie est host-only, `HttpOnly`, `SameSite=Strict`,
`Path=/`, limité à une heure. Il n’a pas l’attribut `Secure`, puisque ce mode
`Path=/`, avec `Max-Age=3600` secondes (une heure). Il n’a pas l’attribut `Secure`, puisque ce mode
testé utilise HTTP loopback et ne revendique pas TLS.
Lectures opérationnelles et mutations exigent la session. Les mutations exigent
@ -109,8 +162,25 @@ zoom, filtres, repli et positions épinglées restent dans l’état du navigate
## Limites
Ce poste est une fondation locale synthétique : pas d’enquête réelle, LAN,
TLS, reverse proxy, service permanent, autonomie réseau, recherche OSINT,
worker distribué ou mutation de la V21. Les anciens modes J2–J5 restent
strictement read-only. `ThreadingHTTPServer` n’est pas présenté comme un serveur
Internet ou une frontière de sécurité de production.
Ce poste est une fondation locale contrôlée : les validations automatisées
emploient exclusivement des fixtures `SPECIMEN`. Il n'autorise ni enquête
réelle, ni LAN, TLS, reverse proxy, service permanent, autonomie réseau,
recherche OSINT, worker distribué ou mutation de la V21. Les anciens modes
J2–J5 restent strictement read-only. `ThreadingHTTPServer` n’est pas présenté
comme un serveur Internet ou une frontière de sécurité de production.
## Validation Web
La cible `make web-workspace-web-check` construit d'abord les bridges C requis,
puis exécute les unités Node, la découverte Python et les parcours navigateur
séquentiels. Le runner navigateur classe chaque scénario avec l’un des statuts
`MISSING`, `LAUNCH_ERROR`, `EXIT_CODE`, `SIGNALED`, `TIMEOUT`, `INTERRUPTED`
ou `SUCCESS`. Sur Linux, il crée et possède le groupe et la session de
processus de chaque scénario ; son nettoyage est limité à ce groupe/session et
envoie `SIGTERM`, attend une grâce bornée, puis escalade avec `SIGKILL` si
nécessaire. Il ne recherche ni ne termine des processus utilisateur.
Les régressions emploient un Firefox, des profils, ports, XDG et bibliothèques
`SPECIMEN` isolés, notamment pour la connexion tardive, l'expiration et la
reconnexion sans redémarrer le serveur. Elles ne constituent pas à elles seules
une certification de sécurité, de production, de navigateur ou de données non
synthétiques.

View file

@ -1,11 +1,19 @@
# Poste Web local — guide d’utilisation
> **Statut CURRENT local :** poste utilisable sur une enquête `SPECIMEN`, au-dessus
> des services C et du JobStore V3 existants. Il ne constitue pas une autorisation
> d’ouvrir une enquête réelle ni un service distant.
> **Statut CURRENT local :** poste utilisable au-dessus des services C et du
> JobStore V3 existants, depuis une bibliothèque d'enquêtes explicitement
> choisie. Les validations emploient des fixtures `SPECIMEN`. Il ne constitue
> ni une autorisation d’ouvrir une enquête réelle, ni un service distant.
## Organisation
À l'ouverture, la bibliothèque affiche les enquêtes locales enregistrées et
permet d'en créer une avec un titre explicite. Une enquête ouverte reste la
seule enquête active de cette instance ; le poste n'en restaure pas une autre
automatiquement. Le titre et la génération de la bibliothèque identifient le
contexte affiché, afin qu'une réponse tardive d'une enquête précédente ne le
repeuple pas.
Le graphe reste la surface principale. La barre haute porte l’identité de
l’enquête, le statut local et la recherche. La rangée suivante change de
projection (`Réseau`, `Preuves`, `Chronologie`, `Infrastructure locale`) sans
@ -82,16 +90,18 @@ Une date sans fuseau ou invalide reste non positionnable.
## Lancement et limites
Créer exclusivement une fixture synthétique dans un répertoire temporaire :
Lancer le poste local sur une bibliothèque explicitement choisie :
```bash
make -j8 tools/local-jobs
tmp_workspace=$(mktemp -d /tmp/labfy-workbench-specimen-XXXXXX)
tools/local-jobs init-j7-specimen --workspace "$tmp_workspace"
make web-workspace-j9 WORKSPACE="$tmp_workspace"
make web PORT=8081 LIBRARY=/chemin/vers/la-bibliotheque-locale
```
L’écoute reste loopback, la session est authentifiée et les mutations exigent
le jeton CSRF. Le poste n’expose pas le répertoire des preuves et n’installe
aucun outil. Les projections vides, erreurs, annulations et limites sont des
états explicites ; elles ne prouvent jamais une absence d’information.
Pour les validations, utiliser seulement une bibliothèque temporaire et des
enquêtes `SPECIMEN`. Les commandes d'exploitation `start`, `status`, `stop` et
`code`, les répertoires XDG privés et les plafonds de réception sont documentés
dans [le contrat du poste Web](../architecture/WEB_WORKSPACE_CONTROL.md).

View file

@ -4,7 +4,7 @@
"type": "module",
"scripts": {
"test": "node --test tests/test_graph_state.mjs",
"test:browser": "node tests/test_browser.mjs && node tests/test_core_browser.mjs && node tests/test_eml_browser.mjs && node tests/test_local_toolkit_browser.mjs && node tests/test_workspace_control_browser.mjs && node tests/test_j8_browser.mjs && node tests/test_j8_runtime_browser.mjs && node tests/test_j9_browser.mjs && node tests/test_workbench_browser.mjs && node tests/test_workbench_populated_browser.mjs && node tests/test_local_workspace_import_browser.mjs"
"test:browser": "node tests/run_browser_suite.mjs"
},
"devDependencies": {
"puppeteer-core": "24.28.0"

View file

@ -118,7 +118,7 @@ let reconnectCount = 0;
let operationalMode = false;
let csrfToken = null;
let workspaceReady = false;
let workspaceMode = "specimen";
let libraryMode = false;
const preparedUploads = new Map();
let jobsRefreshing = false;
let graphRefreshing = false;
@ -135,6 +135,109 @@ let previewSequence = 0;
let reportDraftKey = "labfy-report-draft:unbound";
let evidenceOpenSequence = 0;
let openedEvidenceId = null;
let libraryGeneration = null;
let pendingCreateIntent = null;
let workspaceContext = null;
let workspaceAbortController = null;
const refreshTimers = new Set();
let sessionExpired = false;
function invalidateWorkspaceContext() {
workspaceAbortController?.abort();
workspaceAbortController = null;
workspaceContext = null;
eventSource?.close();
eventSource = null;
for (const timer of refreshTimers) clearInterval(timer);
refreshTimers.clear();
evidenceOpenSequence += 1;
previewSequence += 1;
openedEvidenceId = null;
jobsRefreshing = false;
graphRefreshing = false;
correlationsRefreshing = false;
}
function beginWorkspaceContext(workspaceId, generation) {
invalidateWorkspaceContext();
workspaceAbortController = new AbortController();
workspaceContext = Object.freeze({ workspaceId, generation });
return workspaceContext;
}
function expireAuthenticatedSession() {
if (sessionExpired || !operationalMode) return;
sessionExpired = true;
// CONTRACT: le premier 401 authentifié ferme tous les producteurs de données
// et invalide les secrets en mémoire. Aucun appel refusé n'est rejoué.
invalidateWorkspaceContext();
csrfToken = null;
operationalMode = false;
libraryMode = false;
workspaceReady = false;
libraryGeneration = null;
pendingCreateIntent = null;
preparedUploads.clear();
for (const dialog of document.querySelectorAll("dialog[open]")) dialog.close();
byId("node-context-menu").hidden = true;
byId("workbench-shell").hidden = true;
byId("library-home").hidden = false;
byId("workspace-create").hidden = true;
byId("library-list-section").hidden = true;
byId("library-title").textContent = "Session locale expirée";
byId("library-connection").textContent =
"Le travail en cours a été arrêté. Reconnectez-vous pour reprendre ; aucune action refusée n’a été relancée.";
let reconnect = byId("session-reconnect");
if (!reconnect) {
reconnect = document.createElement("a");
reconnect.id = "session-reconnect";
reconnect.href = "/";
reconnect.textContent = "Se reconnecter";
byId("library-connection").insertAdjacentElement("afterend", reconnect);
}
reconnect.focus();
}
async function detectExpiredSession(response) {
if (!operationalMode || response.status !== 401) return false;
let value = null;
try { value = await response.clone().json(); } catch (_) { return false; }
if (value?.error !== "session_required") return false;
expireAuthenticatedSession();
return true;
}
async function workspaceFetch(path, options = {}) {
const context = workspaceContext;
const controller = workspaceAbortController;
if (!context || !controller) throw new DOMException("Contexte fermé", "AbortError");
const response = await fetch(path, { ...options, signal: controller.signal });
if (await detectExpiredSession(response))
throw new DOMException("Session expirée", "AbortError");
// INVARIANT: une réponse appartient au couple espace/génération qui l'a
// demandée ; elle ne peut jamais repeupler l'espace ouvert ensuite.
if (context !== workspaceContext) throw new DOMException("Contexte remplacé", "AbortError");
Object.defineProperty(response, "_labfyWorkspaceContext", { value: context });
return response;
}
async function workspaceJson(response) {
const value = await response.json();
if (response._labfyWorkspaceContext !== workspaceContext)
throw new DOMException("Contexte remplacé", "AbortError");
return value;
}
async function responseJson(response, fallback) {
let value;
try {
value = await response.json();
} catch (_) {
throw new Error(fallback);
}
if (!response.ok) throw new Error(value.message ?? fallback);
return value;
}
function saveReportDraft() {
if (!operationalMode) return;
@ -153,7 +256,7 @@ function restoreReportDraft() {
try {
const draft = JSON.parse(sessionStorage.getItem(reportDraftKey) ?? "null");
if (!draft) return;
byId("report-name").value = draft.title ?? "Rapport SPECIMEN";
byId("report-name").value = draft.title ?? "Rapport d’enquête";
byId("report-comment").value = draft.comment ?? "";
for (const input of document.querySelectorAll('[name="report-section"]'))
input.checked = draft.sections?.includes(input.value) ?? input.checked;
@ -176,19 +279,19 @@ function invalidateReportPreview(
}
async function postCommand(path, value = {}) {
const response = await fetch(path, {
const response = await workspaceFetch(path, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Labfy-CSRF": csrfToken },
body: JSON.stringify(value),
});
const result = await response.json();
const result = await workspaceJson(response);
if (!response.ok) throw new Error(result.message ?? "Commande refusée");
return result;
}
async function evidenceJson(path) {
const response = await fetch(path, { cache: "no-store" });
const value = await response.json();
const response = await workspaceFetch(path, { cache: "no-store" });
const value = await workspaceJson(response);
if (!response.ok) throw new Error(value.message ?? "Preuve indisponible");
return value;
}
@ -307,14 +410,14 @@ async function openEvidence(evidenceId) {
byId("evidence-status").textContent =
`Intégrité vérifiée · cache ${preview.cache_id.slice(0, 12)} · aperçu ${preview.renderer_version}`;
} catch (error) {
if (sequence === evidenceOpenSequence)
if (error.name !== "AbortError" && sequence === evidenceOpenSequence)
byId("evidence-status").textContent = error.message;
}
}
async function refreshGraphAfterImport() {
const response = await fetch("/api/v1/snapshot", { cache: "no-store" });
const value = await response.json();
const response = await workspaceFetch("/api/v1/snapshot", { cache: "no-store" });
const value = await workspaceJson(response);
if (!response.ok) throw new Error(value.message ?? "Projection indisponible");
snapshot = prepareSnapshot(value); coreMode = true; render();
await refreshPlanner();
@ -330,11 +433,11 @@ async function receiveFile(file, selectionId) {
declared_type: file.type || "application/octet-stream",
selection_id: selectionId,
});
const response = await fetch(`/api/v1/uploads/${intention.upload_id}`, {
const response = await workspaceFetch(`/api/v1/uploads/${intention.upload_id}`, {
method: "PUT", headers: {"Content-Type":"application/octet-stream",
"X-Labfy-CSRF":csrfToken}, body:file,
});
const prepared = await response.json();
const prepared = await workspaceJson(response);
if (!response.ok) throw new Error(prepared.message ?? "Réception refusée");
preparedUploads.set(prepared.upload_id, prepared);
item.textContent = `${prepared.name} — ${prepared.expected_size} octets — ${prepared.recognized_type} — préparé`;
@ -374,9 +477,9 @@ async function refreshJobs() {
if (jobsRefreshing) return;
jobsRefreshing = true;
try {
const response = await fetch("/api/v1/jobs", { cache: "no-store" });
const response = await workspaceFetch("/api/v1/jobs", { cache: "no-store" });
if (!response.ok) return;
const value = await response.json();
const value = await workspaceJson(response);
if (
value.contract !== "labfy.local_jobs.snapshot.v1" ||
!Array.isArray(value.jobs)
@ -449,8 +552,8 @@ async function refreshOperationalGraph() {
if (!operationalMode || graphRefreshing) return;
graphRefreshing = true;
try {
const response = await fetch("/api/v1/snapshot", { cache: "no-store" });
const value = await response.json();
const response = await workspaceFetch("/api/v1/snapshot", { cache: "no-store" });
const value = await workspaceJson(response);
if (!response.ok) throw new Error(value.message ?? "export indisponible");
const fingerprint = snapshotFingerprint(value);
if (fingerprint !== acceptedSnapshotFingerprint) {
@ -492,7 +595,8 @@ async function refreshOperationalGraph() {
"Poste local synchronisé · sans changement";
}
} catch (error) {
byId("connection").textContent = `Dernière vue valide · ${error.message}`;
if (error.name !== "AbortError")
byId("connection").textContent = `Dernière vue valide · ${error.message}`;
} finally {
graphRefreshing = false;
}
@ -502,8 +606,8 @@ async function refreshCorrelations() {
if (!operationalMode || correlationsRefreshing) return;
correlationsRefreshing = true;
try {
const response = await fetch("/api/v1/correlations", { cache: "no-store" });
const value = await response.json();
const response = await workspaceFetch("/api/v1/correlations", { cache: "no-store" });
const value = await workspaceJson(response);
if (
!response.ok ||
value.contract !== "labfy.local_correlation.snapshot.v1"
@ -557,8 +661,9 @@ async function refreshCorrelations() {
? "Aucun rapprochement calculé depuis les observations persistées."
: `${value.groups.length} rapprochements calculés · ${value.complete ? "index complet" : "résultat borné"}`;
} catch (error) {
byId("correlations-note").textContent =
`Index local indisponible · ${error.message}`;
if (error.name !== "AbortError")
byId("correlations-note").textContent =
`Index local indisponible · ${error.message}`;
} finally {
correlationsRefreshing = false;
}
@ -567,8 +672,8 @@ async function refreshCorrelations() {
async function refreshPlanner() {
if (!operationalMode) return;
try {
const response = await fetch("/api/v1/planner", { cache: "no-store" });
const value = await response.json();
const response = await workspaceFetch("/api/v1/planner", { cache: "no-store" });
const value = await workspaceJson(response);
if (!response.ok || value.contract !== "labfy.local_planner.snapshot.v1")
throw new Error(value.message ?? "planner indisponible");
if (value.input_revision === plannerRevision) return;
@ -623,8 +728,9 @@ async function refreshPlanner() {
? "Gain d'information attendu : heuristique locale, jamais un score d'identité."
: "Aucune action locale pertinente dans cette projection.";
} catch (error) {
byId("planner-note").textContent =
`Planner indisponible · ${error.message}`;
if (error.name !== "AbortError")
byId("planner-note").textContent =
`Planner indisponible · ${error.message}`;
}
}
@ -1023,10 +1129,10 @@ async function generateReport() {
byId("report-status").textContent = "Génération locale en cours…";
for (let i = 0; i < 100; i++) {
await new Promise((resolve) => setTimeout(resolve, 100));
const response = await fetch(`/api/v1/reports/${admission.report_id}`, {
const response = await workspaceFetch(`/api/v1/reports/${admission.report_id}`, {
cache: "no-store",
}),
status = await response.json();
status = await workspaceJson(response);
if (status.state === "READY") {
const box = byId("report-status");
box.className = "report-ready";
@ -1074,7 +1180,8 @@ async function runCapability(capability) {
`[data-capability-id="${capability.id}"]`,
);
if (button) button.disabled = true;
const storageKey = `labfy-intent:${node.object_id}:${capability.id}`;
const storageKey = `labfy-intent:${workspaceContext.workspaceId}:` +
`${workspaceContext.generation}:${node.object_id}:${capability.id}`;
const key = sessionStorage.getItem(storageKey) ?? crypto.randomUUID();
sessionStorage.setItem(storageKey, key);
try {
@ -1140,8 +1247,8 @@ function resetGlobalView() {
async function resyncSnapshot(reason) {
byId("connection").textContent = `Rattrapage snapshot — ${reason}`;
try {
const response = await fetch("/api/v1/snapshot?size=demo&revision=latest");
const replacement = await response.json();
const response = await workspaceFetch("/api/v1/snapshot?size=demo&revision=latest");
const replacement = await workspaceJson(response);
if (
!response.ok ||
replacement.contract !== "labfy.web_graph.snapshot.v1" ||
@ -1199,7 +1306,7 @@ function connectEvents() {
eventSource.onerror = () => {
if (eventSource.readyState !== EventSource.CLOSED) {
reconnectCount += 1;
byId("connection").textContent = "Reconnexion au scénario synthétique…";
byId("connection").textContent = "Reconnexion au flux…";
}
};
}
@ -1282,7 +1389,7 @@ function configureControls() {
candidate.id.toLocaleLowerCase("fr").includes(query),
);
if (!node) {
byId("graph-state").textContent = "Aucun objet synthétique trouvé.";
byId("graph-state").textContent = "Aucun objet trouvé.";
return;
}
if (!projection.nodes.some((candidate) => candidate.id === node.id)) {
@ -1439,73 +1546,253 @@ function configureGraphInteractions() {
});
}
async function start() {
async function libraryRequest(path, options = {}) {
const response = await fetch(path, options);
if (await detectExpiredSession(response))
throw new DOMException("Session expirée", "AbortError");
return responseJson(response, "Bibliothèque locale indisponible");
}
function renderLibrary(value) {
if (value.contract !== "labfy.web_library.v1" ||
!Array.isArray(value.entries) || !Number.isInteger(value.generation))
throw new Error("Contrat de bibliothèque inattendu");
libraryGeneration = value.generation;
const list = byId("workspace-list");
list.replaceChildren();
for (const entry of value.entries) {
const item = document.createElement("li");
item.className = "workspace-card";
const heading = document.createElement("h3");
heading.textContent = entry.title;
const state = document.createElement("p");
const active = value.active_workspace_id === entry.workspace_id;
const anotherActive = value.active_workspace_id !== null && !active;
state.textContent = active ? "Ouverte dans cette session" : anotherActive
? "Disponible après redémarrage du poste local"
: entry.state === "READY" ? "Prête à ouvrir" : `État : ${entry.state}`;
const open = document.createElement("button");
open.type = "button";
open.textContent = active ? "Revenir à l’enquête" : "Ouvrir l’enquête";
open.disabled = entry.state !== "READY" || anotherActive;
open.dataset.workspaceId = entry.workspace_id;
open.addEventListener("click", () => void openWorkspace(entry.workspace_id, open));
item.append(heading, state, open);
list.append(item);
}
byId("library-empty").hidden = value.entries.length !== 0;
byId("library-connection").textContent =
`${value.entries.length} enquête(s) · génération ${value.generation}`;
}
async function refreshLibrary() {
const value = await libraryRequest("/api/v1/library", { cache: "no-store" });
renderLibrary(value);
return value;
}
async function showLibrary({ focus = false } = {}) {
invalidateWorkspaceContext();
for (const dialog of document.querySelectorAll("dialog[open]")) dialog.close();
preparedUploads.clear();
workspaceReady = false;
byId("workbench-shell").hidden = true;
byId("library-home").hidden = false;
byId("workspace-create").hidden = false;
byId("library-list-section").hidden = false;
byId("library-title").textContent = "Bibliothèque d’enquêtes";
byId("library-connection").textContent = "Actualisation…";
try {
const initialResponse = await fetch("/api/v1/snapshot", {
cache: "no-store",
});
const initialValue = await initialResponse.json();
snapshot = prepareSnapshot(initialValue);
acceptedSnapshotFingerprint = snapshotFingerprint(initialValue);
await refreshLibrary();
} catch (error) {
byId("library-connection").textContent = error.message;
}
if (focus) byId("library-title").focus();
}
function resetWorkspacePresentation() {
snapshot = prepareSnapshot({ contract: "labfy.web_graph.snapshot.v3", origin: "core",
revision: 0, nodes: [], edges: [], capability_catalog: [], capabilities: [] });
acceptedSnapshotFingerprint = snapshotFingerprint(snapshot);
selectedNodeId = null;
focusNodeId = null;
navigationHistory = [];
reportSelection.clear();
reportPreview = null;
correlationRevision = null;
plannerRevision = null;
plannerValue = null;
preparedUploads.clear();
pinnedPositions.clear();
resetGlobalView();
}
async function activateWorkspace(opened) {
beginWorkspaceContext(opened.workspace_id, opened.generation);
resetWorkspacePresentation();
workspaceReady = true;
byId("library-home").hidden = true;
byId("workbench-shell").hidden = false;
byId("home-button").hidden = false;
byId("investigation-title").textContent = opened.title;
byId("mode-badge").textContent = "Espace local · contrôles locaux";
byId("open-import").disabled = false;
byId("connection").textContent = "Ouverture…";
reportDraftKey = `labfy-report-draft:${opened.workspace_id}:${opened.generation}`;
try {
const response = await workspaceFetch("/api/v1/snapshot", { cache: "no-store" });
const value = await workspaceJson(response);
if (!response.ok) throw new Error(value.message ?? "Projection indisponible");
snapshot = prepareSnapshot(value);
acceptedSnapshotFingerprint = snapshotFingerprint(value);
coreMode = snapshot.origin === "core";
} catch (_) {}
scale = coreMode
? fitGraphScale()
: Math.min(1, Math.max(0.6, (svg.clientWidth - 160) / 700));
syncTypeFilterOptions();
try {
const sessionResponse = await fetch("/api/v1/session", {
cache: "no-store",
});
if (sessionResponse.ok) {
const session = await sessionResponse.json();
csrfToken = session.csrf;
operationalMode = true;
workspaceReady = session.workspace_state === "READY";
byId("workspace-create").hidden = workspaceReady;
byId("open-import").disabled = !workspaceReady;
if (session.title) byId("investigation-title").textContent=session.title;
workspaceMode = session.mode;
byId("mode-badge").textContent = session.mode === "specimen"
? "Mode SPECIMEN" : "Local expérimental";
const investigation =
snapshot.investigation?.id ?? snapshot.investigation_id ?? "unknown";
reportDraftKey = `labfy-report-draft:${session.contract}:${session.origin}:${investigation}`;
scale = fitGraphScale();
syncTypeFilterOptions();
restoreReportDraft();
render();
renderTimeline();
renderReportSelection();
byId("connection").textContent = `Ouverte · génération ${opened.generation}`;
await Promise.all([refreshJobs(), refreshCorrelations(), refreshPlanner()]);
for (const [callback, delay] of [[refreshJobs, 500],
[refreshOperationalGraph, 700], [refreshCorrelations, 900], [refreshPlanner, 1100]])
refreshTimers.add(setInterval(callback, delay));
svg.focus();
} catch (error) {
if (error.name !== "AbortError") {
byId("connection").textContent = `Ouverture incomplète · ${error.message}`;
byId("graph-state").textContent = "La projection de cette enquête est indisponible.";
}
} catch (_) {}
if (coreMode) {
byId("mode-badge").textContent = workspaceMode === "specimen"
? (operationalMode ? "Poste J6 · SPECIMEN · contrôles locaux" : "Snapshot du cœur C · SPECIMEN · lecture seule")
: "Local expérimental · contrôles locaux";
}
}
async function activateLegacyWorkspace(session) {
const workspaceId = session?.investigation_id ?? "legacy-read-only";
const generation = Number.isInteger(session?.generation) ? session.generation : 0;
beginWorkspaceContext(workspaceId, generation);
resetWorkspacePresentation();
workspaceReady = session?.workspace_state === "READY" || session === null;
byId("library-home").hidden = true;
byId("workbench-shell").hidden = false;
byId("home-button").hidden = true;
byId("investigation-title").textContent = session?.title ?? "Enquête locale";
byId("open-import").disabled = !operationalMode || !workspaceReady;
reportDraftKey = `labfy-report-draft:${workspaceId}:${generation}`;
try {
const response = await workspaceFetch("/api/v1/snapshot", { cache: "no-store" });
const value = await workspaceJson(response);
snapshot = prepareSnapshot(value);
acceptedSnapshotFingerprint = snapshotFingerprint(value);
coreMode = snapshot.origin === "core";
byId("mode-badge").textContent = operationalMode
? "Poste local · contrôles locaux"
: coreMode ? "Snapshot du cœur C · lecture seule" : "Démonstration locale · lecture seule";
byId("connection").textContent = snapshot._loadError
? `Erreur d'export cœur · ${snapshot._loadError}`
: "Lecture seule — snapshot cœur chargé";
} else {
byId("connection").textContent = `Connecté · révision ${snapshot.revision}`;
: operationalMode ? "Poste local synchronisé" :
coreMode ? "Lecture seule — snapshot cœur chargé" : `Connecté · révision ${snapshot.revision}`;
scale = coreMode ? fitGraphScale() :
Math.min(1, Math.max(0.6, (svg.clientWidth - 160) / 700));
syncTypeFilterOptions();
restoreReportDraft();
render();
if (snapshot._loadError)
byId("graph-state").textContent = `Snapshot cœur indisponible : ${snapshot._loadError}`;
if (!coreMode) connectEvents();
if (operationalMode) {
await Promise.all([refreshJobs(), refreshCorrelations(), refreshPlanner()]);
for (const [callback, delay] of [[refreshJobs, 500],
[refreshOperationalGraph, 700], [refreshCorrelations, 900], [refreshPlanner, 1100]])
refreshTimers.add(setInterval(callback, delay));
} else {
byId("queue-controls").hidden = true;
}
renderTimeline();
renderReportSelection();
} catch (error) {
if (error.name !== "AbortError") {
byId("connection").textContent = `Chargement impossible · ${error.message}`;
byId("graph-state").textContent = "La projection est indisponible.";
}
}
}
function showLegacyCreation() {
beginWorkspaceContext("legacy-empty", 0);
workspaceReady = false;
byId("workbench-shell").hidden = true;
byId("library-home").hidden = false;
byId("library-title").textContent = "Créer l’enquête locale";
byId("library-connection").textContent = "Aucune enquête ouverte";
byId("library-list-section").hidden = true;
}
async function openWorkspace(workspaceId, button) {
button.disabled = true;
byId("library-connection").textContent = "Ouverture…";
try {
const opened = await libraryRequest("/api/v1/library/open", {
method: "POST",
headers: { "Content-Type": "application/json", "X-Labfy-CSRF": csrfToken },
body: JSON.stringify({ workspace_id: workspaceId, expected_generation: libraryGeneration }),
});
if (opened.contract !== "labfy.web_library.open.v1")
throw new Error("Contrat d’ouverture inattendu");
libraryGeneration = opened.generation;
await activateWorkspace(opened);
} catch (error) {
byId("library-connection").textContent = error.message;
button.disabled = false;
await refreshLibrary().catch(() => {});
}
}
function configureApplication() {
configureControls();
configureGraphInteractions();
render();
if (snapshot._loadError) {
byId("graph-state").textContent =
`Snapshot cœur indisponible : ${snapshot._loadError}`;
}
if (!coreMode) connectEvents();
if (operationalMode) {
byId("workspace-create-form").addEventListener("submit",async(event)=>{
event.preventDefault();
try{await postCommand("/api/v1/workspace",{title:byId("workspace-title").value});
workspaceReady=true;byId("workspace-create").hidden=true;byId("open-import").disabled=false;
await refreshGraphAfterImport();
}catch(error){byId("workspace-create-status").textContent=error.message;}
});
configureImport();
byId("jobs-note").textContent = "Contrôle local authentifié.";
for (const [id, command] of [
configureImport();
byId("home-button").addEventListener("click", () => void showLibrary({ focus: true }));
byId("workspace-create-form").addEventListener("submit", async (event) => {
event.preventDefault();
const button = event.currentTarget.querySelector("button");
button.disabled = true;
byId("workspace-create-status").textContent = "Création…";
try {
const title = byId("workspace-title").value.trim();
if (pendingCreateIntent?.title !== title)
pendingCreateIntent = { title, idempotencyKey: crypto.randomUUID() };
const path = libraryMode ? "/api/v1/library/workspaces" : "/api/v1/workspace";
const body = libraryMode
? { title, idempotency_key: pendingCreateIntent.idempotencyKey }
: { title };
const created = await libraryRequest(path, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Labfy-CSRF": csrfToken },
body: JSON.stringify(body),
});
if (libraryMode && created.contract !== "labfy.web_library.workspace.v1")
throw new Error("Contrat de création inattendu");
byId("workspace-title").value = "";
pendingCreateIntent = null;
if (libraryMode) {
byId("workspace-create-status").textContent = `« ${created.title} » créée. Ouvrez-la depuis la liste.`;
await refreshLibrary();
document.querySelector(`[data-workspace-id="${CSS.escape(created.workspace_id)}"]`)?.focus();
} else {
byId("workspace-create").hidden = true;
await activateLegacyWorkspace({ workspace_state: "READY", title,
investigation_id: created.investigation_id, generation: 0 });
}
} catch (error) {
byId("workspace-create-status").textContent = error.message;
} finally { button.disabled = false; }
});
byId("jobs-note").textContent = "Contrôle local authentifié.";
for (const [id, command] of [
["queue-pause", "pause"],
["queue-resume", "resume"],
["queue-stop", "stop"],
]) {
]) {
byId(id).addEventListener(
"click",
() =>
@ -1515,11 +1802,8 @@ async function start() {
byId("jobs-note").textContent = error.message;
}),
);
}
await refreshJobs();
await refreshCorrelations();
await refreshPlanner();
byId("planner-launch").addEventListener("click", async () => {
}
byId("planner-launch").addEventListener("click", async () => {
const ids = [
...byId("planner-list").querySelectorAll("input:checked"),
].map((input) => input.value);
@ -1538,40 +1822,49 @@ async function start() {
} catch (error) {
byId("planner-note").textContent = error.message;
}
});
byId("report-preview").addEventListener(
});
byId("report-preview").addEventListener(
"click",
() =>
void previewReport().catch((error) => {
byId("report-preview-content").textContent = error.message;
}),
);
byId("report-generate").addEventListener(
);
byId("report-generate").addEventListener(
"click",
() =>
void generateReport().catch((error) => {
byId("report-status").textContent = error.message;
}),
);
restoreReportDraft();
for (const input of [
);
for (const input of [
byId("report-name"),
byId("report-comment"),
...document.querySelectorAll('[name="report-section"]'),
]) {
]) {
input.addEventListener("input", () => {
invalidateReportPreview();
saveReportDraft();
});
}
renderTimeline();
renderReportSelection();
setInterval(refreshJobs, 500);
setInterval(refreshOperationalGraph, 700);
setInterval(refreshCorrelations, 900);
setInterval(refreshPlanner, 1100);
} else {
byId("queue-controls").hidden = true;
}
}
async function start() {
try {
const sessionResponse = await fetch("/api/v1/session", { cache: "no-store" });
const session = sessionResponse.ok ? await sessionResponse.json() : null;
csrfToken = session?.csrf ?? null;
operationalMode = session !== null;
libraryMode = session?.library_mode === true;
configureApplication();
// WHY: seule l'application bibliothèque ouvre toujours sur l'accueil ; les
// serveurs historiques et de démonstration conservent leur contrat direct.
if (libraryMode) await showLibrary();
else if (session?.workspace_state === "EMPTY") showLegacyCreation();
else await activateLegacyWorkspace(session);
} catch (error) {
byId("library-home").hidden = false;
byId("library-connection").textContent = error.message;
}
}
@ -1589,6 +1882,11 @@ window.__LABFY_TEST__ = {
reconnectCount,
coreMode,
contract: snapshot.contract,
workspaceId: workspaceContext?.workspaceId ?? null,
workspaceGeneration: workspaceContext?.generation ?? null,
sessionExpired,
refreshTimerCount: refreshTimers.size,
hasCsrf: csrfToken !== null,
}),
processEventData,
selectNode,

View file

@ -7,14 +7,28 @@
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<section id="workspace-create" class="modal-card" hidden aria-labelledby="create-title">
<h1 id="create-title">Créer l’enquête</h1>
<p>La destination locale a été fixée au lancement. Aucun exemple ne sera ajouté.</p>
<form id="workspace-create-form"><label>Titre
<input id="workspace-title" maxlength="160" required /></label>
<button type="submit">Créer l’enquête vide</button></form>
<p id="workspace-create-status" role="alert"></p>
</section>
<main id="library-home" class="library-home" aria-labelledby="library-title" hidden>
<header class="library-header">
<div><span class="eyebrow">Poste local</span><h1 id="library-title" tabindex="-1">Bibliothèque d’enquêtes</h1></div>
<span id="library-connection" role="status">Chargement…</span>
</header>
<section id="workspace-create" class="library-create" aria-labelledby="create-title">
<h2 id="create-title">Nouvelle enquête</h2>
<p>Crée un espace local vide. Aucun contenu d’exemple n’est ajouté.</p>
<form id="workspace-create-form">
<label for="workspace-title">Titre de l’enquête</label>
<div class="library-create-row"><input id="workspace-title" maxlength="160" required autocomplete="off" />
<button type="submit">Créer</button></div>
</form>
<p id="workspace-create-status" role="alert"></p>
</section>
<section id="library-list-section" aria-labelledby="workspace-list-title">
<h2 id="workspace-list-title">Enquêtes disponibles</h2>
<p id="library-empty" class="empty-state" hidden>Aucune enquête. Créez votre premier espace local pour commencer.</p>
<ul id="workspace-list" class="workspace-list" aria-live="polite"></ul>
</section>
</main>
<div id="workbench-shell" hidden>
<dialog id="import-dialog" aria-labelledby="import-title">
<form method="dialog"><button class="dialog-close" aria-label="Fermer">×</button></form>
<h2 id="import-title">Ajouter des preuves</h2>
@ -31,7 +45,7 @@
<div class="brand">
<strong>LABFY</strong><span id="investigation-title">Enquête locale</span
><span id="mode-badge" class="badge"
>Prototype — données synthétiques — lecture seule</span
>Espace local · contrôles locaux</span
>
</div>
<label class="global-search"
@ -54,6 +68,8 @@
</button>
</div>
<div class="graph-tools">
<button id="home-button">Accueil</button
>
<button id="open-import">Ajouter des preuves</button
>
<button id="back" disabled>← Retour</button
@ -89,7 +105,7 @@
<svg
id="graph"
tabindex="0"
aria-label="Réseau d’investigation synthétique"
aria-label="Réseau d’investigation"
>
<defs>
<marker
@ -231,7 +247,7 @@
<input
id="report-name"
maxlength="160"
value="Rapport SPECIMEN" /></label
value="Rapport d’enquête" /></label
><label
>Commentaire humain
<textarea id="report-comment" maxlength="1000"></textarea>
@ -293,6 +309,7 @@
<ol id="observation-list"></ol>
</section>
</dialog>
</div>
<script type="module" src="app.js"></script>
</body>
</html>

View file

@ -3,7 +3,8 @@
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Labfy — session locale</title><link rel="stylesheet" href="styles.css"></head>
<body class="login"><main><section class="login-card"><h1>Labfy · poste local</h1>
<p>Entrez le code éphémère affiché par le lanceur. Il n’est ni placé dans l’URL ni conservé dans le navigateur.</p>
<form id="login-form"><label>Code de session <input id="bootstrap-code" type="password" autocomplete="off" required></label>
<p>Ce poste fonctionne uniquement en local. Entrez le code éphémère affiché dans le terminal qui a lancé Labfy.</p>
<p class="login-privacy">Le code n’est ni placé dans l’URL, ni enregistré dans le navigateur, ni affiché après saisie.</p>
<form id="login-form"><label for="bootstrap-code">Code de session</label><input id="bootstrap-code" type="password" autocomplete="one-time-code" required autofocus>
<button type="submit">Ouvrir la session</button></form><p id="login-status" role="alert"></p>
</section></main><script type="module" src="login.js"></script></body></html>

View file

@ -2,12 +2,27 @@ const form = document.getElementById("login-form");
const status = document.getElementById("login-status");
form.addEventListener("submit", async (event) => {
event.preventDefault();
const bootstrapCode = document.getElementById("bootstrap-code").value;
const response = await fetch("/api/v1/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bootstrap_code: bootstrapCode }),
});
if (response.ok) location.replace("/");
else status.textContent = "Code refusé ou session indisponible.";
const input = document.getElementById("bootstrap-code");
const button = form.querySelector("button");
button.disabled = true;
status.textContent = "Ouverture de la session locale…";
try {
const response = await fetch("/api/v1/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bootstrap_code: input.value }),
});
// INVARIANT: le secret saisi ne rejoint ni l'URL, ni le stockage Web,
// ni un message d'erreur rendu à l'écran.
input.value = "";
if (!response.ok) throw new Error("Code refusé ou session indisponible.");
location.replace("/");
} catch (error) {
input.value = "";
status.textContent = error.message === "Failed to fetch"
? "Le service local ne répond pas. Vérifiez qu’il est toujours lancé."
: error.message;
button.disabled = false;
input.focus();
}
});

View file

@ -76,6 +76,77 @@ textarea:focus-visible,
overflow: hidden;
clip: rect(0, 0, 0, 0);
}
#workbench-shell {
height: 100%;
}
.library-home {
width: min(72rem, 100%);
height: 100%;
margin: 0 auto;
padding: clamp(1rem, 4vw, 3rem);
overflow: auto;
}
.library-header {
display: flex;
align-items: end;
justify-content: space-between;
gap: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.library-header h1 {
margin: 0.25rem 0 0;
color: var(--accent);
}
#library-connection {
color: var(--muted);
text-align: right;
}
.library-create {
margin: 1.5rem 0 2rem;
padding: 1.25rem;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 0.75rem;
}
.library-create h2,
.library-create p {
margin-top: 0;
}
.library-create-row {
display: flex;
gap: 0.5rem;
margin-top: 0.4rem;
}
.library-create-row input {
flex: 1;
min-width: 0;
}
.workspace-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(18rem, 100%), 1fr));
gap: 1rem;
padding: 0;
list-style: none;
}
.workspace-card,
.empty-state {
padding: 1rem;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 0.75rem;
}
.workspace-card h3 {
margin: 0;
overflow-wrap: anywhere;
}
.workspace-card p,
.login-privacy {
color: var(--muted);
}
.workspace-card button {
width: 100%;
}
.topbar {
height: var(--top);
display: grid;
@ -457,6 +528,10 @@ body.login .workbench {
border: 1px solid var(--border);
border-radius: 0.75rem;
}
.login-card form {
display: grid;
gap: 0.65rem;
}
@media (max-width: 900px) {
:root {
--top: 6.2rem;
@ -506,6 +581,25 @@ body.login .workbench {
display: none;
}
}
@media (max-width: 700px) {
.library-header,
.library-create-row {
align-items: stretch;
flex-direction: column;
}
#library-connection {
text-align: left;
}
}
@media (forced-colors: active) {
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
#graph:focus-visible {
outline-color: Highlight;
}
}
@media (max-width: 540px) {
.badge {
display: none;

View file

@ -7,7 +7,7 @@ import argparse
import subprocess
from pathlib import Path
from workspace_server import main as serve_main
from web_app import main as web_app_main
def prepare(workspace: Path, bridge: Path):
@ -27,12 +27,11 @@ def prepare(workspace: Path, bridge: Path):
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--workspace", type=Path, required=True)
parser.add_argument("--port", type=int, default=8765)
parser.add_argument("--port", type=int, default=8081)
args = parser.parse_args()
repository = Path(__file__).resolve().parents[2]
bridge = repository / "tools" / "local-jobs"
prepare(args.workspace.resolve(), bridge)
import sys
sys.argv = [sys.argv[0], "--workspace", str(args.workspace.resolve()),
"--bridge", str(bridge), "--port", str(args.port)]
serve_main()
raise SystemExit(web_app_main(["serve", "--workspace",
str(args.workspace.resolve()), "--bridge", str(bridge),
"--port", str(args.port)]))

View file

@ -0,0 +1,36 @@
import { spawn } from "node:child_process";
function report(message) {
if (typeof process.send !== "function") {
process.exit(70);
return;
}
process.send(message, () => process.exit(0));
}
let launch;
try {
launch = JSON.parse(Buffer.from(process.argv[2] ?? "", "base64url").toString("utf8"));
} catch (error) {
report({ type: "host_error", message: error.message });
}
if (launch) {
let settled = false;
const child = spawn(launch.command, launch.args, {
cwd: launch.cwd,
env: process.env,
stdio: ["ignore", "inherit", "inherit"],
});
child.once("error", (error) => {
if (settled) return;
settled = true;
report({ type: "launch_error", message: error.message, code: error.code ?? null });
});
child.once("exit", (code, signal) => {
if (settled) return;
settled = true;
report({ type: "result", code, signal });
});
}

View file

@ -0,0 +1,232 @@
import { spawn } from "node:child_process";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
const host_path = fileURLToPath(new URL("owned_process_host.mjs", import.meta.url));
export const PROCESS_STATUSES = Object.freeze([
"MISSING", "LAUNCH_ERROR", "EXIT_CODE", "SIGNALED", "TIMEOUT", "INTERRUPTED", "SUCCESS",
]);
function positive_integer(value, name) {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new TypeError(`${name} doit être un entier positif.`);
}
}
function read_linux_identity(pid) {
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
const suffix = stat.slice(stat.lastIndexOf(")") + 2).trim().split(/\s+/);
return {
pid,
process_group_id: Number.parseInt(suffix[2], 10),
session_id: Number.parseInt(suffix[3], 10),
start_time: suffix[19],
};
}
function validate_owned_session(child) {
const identity = read_linux_identity(child.pid);
if (identity.process_group_id !== child.pid || identity.session_id !== child.pid) {
throw new Error(`session détachée invalide pour le PID ${child.pid}`);
}
return identity;
}
function signal_owned_group(ownership, signal) {
if (!ownership.active) return false;
// INVARIANT: Linux cannot reuse a PGID/SID while a member of this owned
// session remains. ESRCH closes ownership permanently, so later PID reuse
// can never become a signal target of this supervisor.
try {
process.kill(-ownership.identity.process_group_id, signal);
return true;
} catch (error) {
if (error.code === "ESRCH") {
ownership.active = false;
return false;
}
throw error;
}
}
function group_exists(ownership) {
return signal_owned_group(ownership, 0);
}
function delay(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
async function stop_owned_group(ownership, term_grace_ms, kill_grace_ms) {
if (!ownership || !group_exists(ownership)) return;
signal_owned_group(ownership, "SIGTERM");
await delay(term_grace_ms);
if (!group_exists(ownership)) return;
signal_owned_group(ownership, "SIGKILL");
const deadline = Date.now() + kill_grace_ms;
while (Date.now() < deadline && group_exists(ownership)) {
await delay(Math.min(10, Math.max(1, deadline - Date.now())));
}
// WHY: a non-reaped zombie can keep kill(0) true. The verdict remains
// bounded and ownership is retired instead of ever targeting this PGID again.
if (group_exists(ownership)) ownership.active = false;
}
function destroy_pipes(child) {
child.stdout?.unpipe();
child.stderr?.unpipe();
child.stdout?.destroy();
child.stderr?.destroy();
if (child.connected) child.disconnect();
}
function status_from_exit(code, signal) {
if (signal !== null) return "SIGNALED";
if (code !== 0) return "EXIT_CODE";
return "SUCCESS";
}
export function create_interruption_controller(signals = ["SIGINT", "SIGTERM", "SIGHUP"]) {
const controller = new AbortController();
let interrupted_signal = null;
const handlers = new Map();
for (const signal of signals) {
const handler = () => {
if (controller.signal.aborted) return;
interrupted_signal = signal;
controller.abort(signal);
};
handlers.set(signal, handler);
process.on(signal, handler);
}
return {
signal: controller.signal,
get interrupted_signal() { return interrupted_signal; },
dispose() {
for (const [signal, handler] of handlers) process.off(signal, handler);
},
};
}
export function supervise_process({
command,
args = [],
cwd = process.cwd(),
timeout_ms,
term_grace_ms = 1000,
kill_grace_ms = 1000,
abort_signal,
stdout = process.stdout,
stderr = process.stderr,
}) {
positive_integer(timeout_ms, "timeout_ms");
positive_integer(term_grace_ms, "term_grace_ms");
positive_integer(kill_grace_ms, "kill_grace_ms");
return new Promise((resolve) => {
let ownership = null;
let target_result = null;
let stopping_status = null;
let settled = false;
let cleanup_started = false;
let child;
let timeout;
const payload = Buffer.from(JSON.stringify({ command, args, cwd }), "utf8").toString("base64url");
const finish = async (result) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
abort_signal?.removeEventListener("abort", on_abort);
destroy_pipes(child);
await stop_owned_group(ownership, term_grace_ms, kill_grace_ms);
resolve(result);
};
const begin_cleanup = (result) => {
if (cleanup_started) return;
cleanup_started = true;
void finish(result);
};
const request_stop = (status, interrupted_signal = null) => {
if (stopping_status) return;
stopping_status = status;
begin_cleanup({
status,
code: target_result?.code ?? null,
signal: target_result?.signal ?? interrupted_signal,
error: null,
});
};
const on_abort = () => request_stop("INTERRUPTED", abort_signal.reason ?? null);
try {
child = spawn(process.execPath, [host_path, payload], {
cwd,
env: process.env,
detached: true,
stdio: ["ignore", "pipe", "pipe", "ipc"],
});
} catch (error) {
resolve({ status: "LAUNCH_ERROR", code: null, signal: null, error: error.message });
return;
}
// CONTRACT: spawn() returns only after the detached child has performed
// setsid(2). Capture and validate that fresh identity synchronously, before
// a pre-aborted signal or a very short timeout can request cleanup.
try {
ownership = { identity: validate_owned_session(child), active: true };
} catch (error) {
child.kill("SIGKILL");
destroy_pipes(child);
resolve({ status: "LAUNCH_ERROR", code: null, signal: null, error: error.message });
return;
}
child.stdout.pipe(stdout, { end: false });
child.stderr.pipe(stderr, { end: false });
child.on("message", (message) => {
if (settled || !message || typeof message !== "object") return;
if (message.type === "launch_error" || message.type === "host_error") {
begin_cleanup({
status: "LAUNCH_ERROR", code: null, signal: null,
error: message.message ?? "échec de lancement",
});
return;
}
if (message.type === "result") {
target_result = { code: message.code ?? null, signal: message.signal ?? null };
begin_cleanup({
status: status_from_exit(target_result.code, target_result.signal),
...target_result,
error: null,
});
}
});
child.once("error", (error) => {
begin_cleanup({ status: "LAUNCH_ERROR", code: null, signal: null, error: error.message });
});
child.once("exit", (code, signal) => {
if (settled || cleanup_started || target_result) return;
setImmediate(() => {
if (settled || cleanup_started || target_result) return;
begin_cleanup({ status: status_from_exit(code, signal), code, signal, error: null });
});
});
timeout = setTimeout(() => request_stop("TIMEOUT"), timeout_ms);
abort_signal?.addEventListener("abort", on_abort, { once: true });
if (abort_signal?.aborted) on_abort();
});
}

View file

@ -0,0 +1,137 @@
import { access } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
PROCESS_STATUSES,
create_interruption_controller,
supervise_process,
} from "./owned_process_supervisor.mjs";
// CONTRACT: this ordered list is the complete browser-validation suite.
export const scenarios = Object.freeze([
"test_browser.mjs",
"test_core_browser.mjs",
"test_eml_browser.mjs",
"test_local_toolkit_browser.mjs",
"test_workspace_control_browser.mjs",
"test_j8_browser.mjs",
"test_j8_runtime_browser.mjs",
"test_j9_browser.mjs",
"test_workbench_browser.mjs",
"test_workbench_populated_browser.mjs",
"test_local_workspace_import_browser.mjs",
// Le scénario import/rapport précédent et ce cycle du vrai launcher forment
// la preuve E2E : données SPECIMEN, rapport, bibliothèque, redémarrages et A/B.
"test_web_app_browser.mjs",
// CONTRACT: ce parcours Firefox exerce le vrai formulaire et une expiration
// de harness isolée ; il démontre l'arrêt du polling et la reconnexion sans
// rejouer une mutation dont l'admission n'est pas connue.
"test_session_lifetime_browser.mjs",
]);
const tests_directory = fileURLToPath(new URL(".", import.meta.url));
function print_result(scenario, result, started_ms, output) {
output.log(
`SCENARIO fin ${scenario} ${new Date().toISOString()} résultat=${result.status} ` +
`code=${result.code ?? "null"} signal=${result.signal ?? "null"} ` +
`durée_ms=${Date.now() - started_ms}`,
);
if (result.error) output.error(`SCENARIO erreur ${scenario}: ${result.error}`);
}
export function summarize_browser_results(results, expected_count) {
const counts = Object.fromEntries(PROCESS_STATUSES.map((status) => [status, 0]));
for (const result of results) counts[result.status] += 1;
const failures = results.filter(({ status }) => status !== "SUCCESS");
const executed = results.filter(({ status }) => status !== "MISSING").length;
return {
counts,
failures,
executed,
exit_code: failures.length === 0 && executed === expected_count ? 0 : 1,
};
}
export async function run_browser_suite({
scenario_names = scenarios,
directory = tests_directory,
timeout_ms,
term_grace_ms = 1000,
kill_grace_ms = 1000,
interruption = create_interruption_controller(),
output = console,
} = {}) {
const results = [];
try {
for (const scenario of scenario_names) {
if (interruption.signal.aborted) break;
const started_ms = Date.now();
output.log(`SCENARIO début ${scenario} ${new Date().toISOString()}`);
const scenario_path = resolve(directory, scenario);
try {
await access(scenario_path);
} catch {
const missing = { status: "MISSING", code: null, signal: null, error: null };
print_result(scenario, missing, started_ms, output);
results.push({ scenario, ...missing });
continue;
}
const result = await supervise_process({
command: process.execPath,
args: [scenario_path],
cwd: directory,
timeout_ms,
term_grace_ms,
kill_grace_ms,
abort_signal: interruption.signal,
});
print_result(scenario, result, started_ms, output);
results.push({ scenario, ...result });
// CONTRACT: an operator interruption cancels the current owned unit and
// prevents every later scenario from being launched.
if (result.status === "INTERRUPTED") break;
}
if (interruption.signal.aborted && !results.some(({ status }) => status === "INTERRUPTED")) {
results.push({
scenario: "<suite>", status: "INTERRUPTED", code: null,
signal: interruption.interrupted_signal, error: null,
});
}
} finally {
interruption.dispose();
}
const summary = summarize_browser_results(results, scenario_names.length);
output.log(
`SUITE navigateur scénarios_exécutés=${summary.executed}/${scenario_names.length} ` +
`échecs=${summary.failures.length} ` +
`résultats=${PROCESS_STATUSES.map((status) => `${status}:${summary.counts[status]}`).join(",")}`,
);
return {
results,
counts: summary.counts,
exit_code: summary.exit_code,
};
}
async function main() {
const timeout_ms = Number.parseInt(process.env.LABFY_BROWSER_SCENARIO_TIMEOUT_MS ?? "300000", 10);
if (!Number.isSafeInteger(timeout_ms) || timeout_ms <= 0) {
console.error("LABFY_BROWSER_SCENARIO_TIMEOUT_MS doit être un entier positif.");
process.exitCode = 2;
return;
}
const summary = await run_browser_suite({ timeout_ms });
process.exitCode = summary.exit_code;
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
await main();
}

View file

@ -0,0 +1,199 @@
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Writable } from "node:stream";
import test from "node:test";
import {
create_interruption_controller,
supervise_process,
} from "./owned_process_supervisor.mjs";
import { run_browser_suite, summarize_browser_results } from "./run_browser_suite.mjs";
const SHORT = { timeout_ms: 1000, term_grace_ms: 40, kill_grace_ms: 120 };
function null_output() {
return new Writable({ write(_chunk, _encoding, callback) { callback(); } });
}
async function wait_for(predicate, timeout_ms = 1000) {
const deadline = Date.now() + timeout_ms;
while (Date.now() < deadline) {
if (await predicate()) return;
await new Promise((resolve) => setTimeout(resolve, 10));
}
assert.fail("condition non satisfaite avant la limite");
}
async function process_is_running(pid) {
try {
const stat = await readFile(`/proc/${pid}/stat`, "utf8");
return stat.slice(stat.lastIndexOf(")") + 2).split(/\s+/)[0] !== "Z";
} catch (error) {
if (error.code === "ENOENT") return false;
throw error;
}
}
test("classifie succès, code non nul, signal et lancement impossible", async () => {
const silent = null_output();
const common = { ...SHORT, stdout: silent, stderr: silent };
const success = await supervise_process({
...common, command: process.execPath, args: ["-e", "process.exit(0)"],
});
const exit_code = await supervise_process({
...common, command: process.execPath, args: ["-e", "process.exit(17)"],
});
const signaled = await supervise_process({
...common,
command: process.execPath,
args: ["-e", "process.kill(process.pid, 'SIGUSR2')"],
});
const launch_error = await supervise_process({
...common, command: "/chemin/SPECIMEN/inexistant", args: [],
});
assert.deepEqual(
[success.status, exit_code.status, signaled.status, launch_error.status],
["SUCCESS", "EXIT_CODE", "SIGNALED", "LAUNCH_ERROR"],
);
assert.equal(exit_code.code, 17);
assert.equal(signaled.signal, "SIGUSR2");
assert.match(launch_error.error, /ENOENT/);
});
test("chaque verdict négatif impose un code global non nul", () => {
for (const status of ["MISSING", "LAUNCH_ERROR", "EXIT_CODE", "SIGNALED", "TIMEOUT", "INTERRUPTED"]) {
assert.equal(summarize_browser_results([{ status }], 1).exit_code, 1, status);
}
assert.equal(summarize_browser_results([{ status: "SUCCESS" }], 1).exit_code, 0);
});
test("le timeout escalade à SIGKILL quand le scénario ignore SIGTERM", async () => {
const started = Date.now();
const result = await supervise_process({
command: process.execPath,
args: ["-e", "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)"],
timeout_ms: 80,
term_grace_ms: 40,
kill_grace_ms: 120,
stdout: null_output(),
stderr: null_output(),
});
assert.equal(result.status, "TIMEOUT");
assert.ok(Date.now() - started < 600, "le délai total doit rester borné");
});
test("un descendant qui garde les pipes ouverts est nettoyé sans bloquer close", async () => {
const directory = await mkdtemp(join(tmpdir(), "labfy-runner-SPECIMEN-"));
const pid_file = join(directory, "descendant.pid");
const child_source = [
"const { spawn } = require('node:child_process');",
"const { writeFileSync } = require('node:fs');",
"const descendant = spawn(process.execPath, ['-e', `process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)`], { stdio: 'inherit' });",
"writeFileSync(process.argv[1], String(descendant.pid));",
"process.exit(0);",
].join("\n");
try {
const started = Date.now();
const result = await supervise_process({
command: process.execPath,
args: ["-e", child_source, pid_file],
cwd: directory,
...SHORT,
stdout: null_output(),
stderr: null_output(),
});
const descendant_pid = Number.parseInt(await readFile(pid_file, "utf8"), 10);
assert.equal(result.status, "SUCCESS");
assert.ok(Date.now() - started < 600, "les pipes hérités ne doivent pas retenir le verdict");
await wait_for(async () => !(await process_is_running(descendant_pid)));
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test("le nettoyage du groupe possédé épargne un témoin indépendant", async () => {
const witness = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {
detached: true,
stdio: "ignore",
});
await new Promise((resolve, reject) => {
witness.once("spawn", resolve);
witness.once("error", reject);
});
try {
const result = await supervise_process({
command: process.execPath,
args: ["-e", "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)"],
timeout_ms: 80,
term_grace_ms: 40,
kill_grace_ms: 120,
stdout: null_output(),
stderr: null_output(),
});
assert.equal(result.status, "TIMEOUT");
assert.equal(await process_is_running(witness.pid), true);
} finally {
try { process.kill(-witness.pid, "SIGKILL"); } catch (error) {
if (error.code !== "ESRCH") throw error;
}
}
});
test("SIGINT interrompt le scénario courant et produit un verdict unique", async () => {
const interruption = create_interruption_controller(["SIGINT"]);
const pending = supervise_process({
command: process.execPath,
args: ["-e", "setInterval(() => {}, 1000)"],
...SHORT,
abort_signal: interruption.signal,
stdout: null_output(),
stderr: null_output(),
});
setTimeout(() => process.kill(process.pid, "SIGINT"), 60);
try {
const result = await pending;
assert.equal(result.status, "INTERRUPTED");
assert.equal(result.signal, "SIGINT");
} finally {
interruption.dispose();
}
});
test("le runner classe MISSING, ne lance pas la suite après interruption et échoue globalement", async () => {
const directory = await mkdtemp(join(tmpdir(), "labfy-suite-SPECIMEN-"));
const first = join(directory, "first.mjs");
const forbidden = join(directory, "forbidden.mjs");
const marker = join(directory, "forbidden-ran");
await writeFile(first, "setInterval(() => {}, 1000);\n", "utf8");
await writeFile(forbidden, `import { writeFileSync } from 'node:fs'; writeFileSync(${JSON.stringify(marker)}, 'bad');\n`, "utf8");
const interruption = create_interruption_controller(["SIGINT"]);
const output = { log() {}, error() {} };
setTimeout(() => process.kill(process.pid, "SIGINT"), 60);
try {
const summary = await run_browser_suite({
scenario_names: ["missing.mjs", "first.mjs", "forbidden.mjs"],
directory,
timeout_ms: 1000,
term_grace_ms: 40,
kill_grace_ms: 120,
interruption,
output,
});
assert.deepEqual(summary.results.map(({ status }) => status), ["MISSING", "INTERRUPTED"]);
assert.equal(summary.exit_code, 1);
assert.equal(await readFile(marker, "utf8").then(() => true, () => false), false);
} finally {
interruption.dispose();
await rm(directory, { recursive: true, force: true });
}
});

View file

@ -0,0 +1,126 @@
"""Session locale : connexion tardive, expiration et nouvelle authentification."""
import http.client
import json
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
from unittest import mock
WEB = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(WEB))
import workspace_server # noqa: E402
class SessionLifetimeTest(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory(prefix="labfy-session-SPECIMEN-")
self.now = 1000.0
clock = mock.Mock(wraps=time)
clock.monotonic.side_effect = lambda: self.now
self.clock_patch = mock.patch.object(workspace_server, "time", clock)
self.clock_patch.start()
self.addCleanup(self.clock_patch.stop)
self.addCleanup(self.temporary.cleanup)
self.server = workspace_server.WorkspaceServer(
("127.0.0.1", 0), workspace_server.Handler,
workspace=Path(self.temporary.name), bridge=WEB / "unused-SPECIMEN",
bootstrap="code-SPECIMEN-only")
self.server.bridge_call = mock.Mock(
side_effect=AssertionError("L'authentification ne doit pas ouvrir d'enquête"))
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=3)
self.assertFalse(self.thread.is_alive())
self.server.bridge_call.assert_not_called()
def request(self, method, path, *, cookie=None, body=None, origin=None):
connection = http.client.HTTPConnection(
"127.0.0.1", self.server.server_port, timeout=3)
headers = {"Host": self.server.authority}
if cookie:
headers["Cookie"] = cookie
if body is not None:
headers.update({"Content-Type": "application/json",
"Origin": origin or self.server.origin})
try:
connection.request(method, path, headers=headers,
body=json.dumps(body) if body is not None else None)
response = connection.getresponse()
return response.status, dict(response.getheaders()), response.read()
finally:
connection.close()
def login(self):
status, headers, body = self.request("POST", "/api/v1/session",
body={"bootstrap_code": "code-SPECIMEN-only"})
self.assertEqual(status, 200)
self.assertTrue(json.loads(body)["authenticated"])
self.assertIn("HttpOnly", headers["Set-Cookie"])
self.assertIn("SameSite=Strict", headers["Set-Cookie"])
self.assertIn("Max-Age=3600", headers["Set-Cookie"])
return headers["Set-Cookie"].split(";", 1)[0]
def test_production_ttl_remains_one_hour(self):
# INVARIANT: le TTL court du harness navigateur est opt-in ; les
# constructions normales et leur cookie restent fixés à une heure.
self.assertEqual(workspace_server.SESSION_TTL_SECONDS, 3600)
self.assertEqual(self.server.session_ttl_seconds, 3600)
self.login()
self.assertEqual(self.server.session_deadline, self.now + 3600)
def test_first_login_after_two_hours_has_its_own_lifetime(self):
self.now += 7200
cookie = self.login()
self.assertEqual(self.request("GET", "/api/v1/session", cookie=cookie)[0], 200)
deadline = self.server.session_deadline
self.assertEqual(deadline, self.now + 3600)
self.now += 3599
self.assertEqual(self.request("GET", "/api/v1/session", cookie=cookie)[0], 200)
self.assertEqual(self.server.session_deadline, deadline)
self.now += 1
self.assertEqual(self.request("GET", "/api/v1/session", cookie=cookie)[0], 401)
def test_reauthentication_renews_session_without_reviving_old_cookie(self):
old_cookie = self.login()
old_csrf = self.server.csrf
self.now += 3601
self.assertEqual(self.request("GET", "/api/v1/session", cookie=old_cookie)[0], 401)
cookie = self.login()
self.assertNotEqual(cookie, old_cookie)
self.assertNotEqual(self.server.csrf, old_csrf)
self.assertEqual(self.request("GET", "/api/v1/session", cookie=old_cookie)[0], 401)
status, _, body = self.request("GET", "/api/v1/session", cookie=cookie)
self.assertEqual(status, 200)
self.assertEqual(json.loads(body)["csrf"], self.server.csrf)
status, _, html = self.request("GET", "/", cookie=cookie)
self.assertEqual(status, 200)
self.assertNotIn(b'id="login-form"', html)
self.assertEqual(self.server.session_deadline, self.now + 3600)
def test_invalid_login_never_extends_or_replaces_a_session(self):
cookie = self.login()
before = (self.server.session, self.server.csrf, self.server.session_deadline)
for expired in (False, True):
if expired:
self.now += 3601
for code, origin in (("wrong-SPECIMEN", self.server.origin),
("code-SPECIMEN-only", "http://foreign.test")):
status, _, _ = self.request("POST", "/api/v1/session",
body={"bootstrap_code": code}, origin=origin)
self.assertEqual(status, 403)
self.assertEqual((self.server.session, self.server.csrf,
self.server.session_deadline), before)
self.assertEqual(self.request("GET", "/api/v1/session", cookie=cookie)[0],
401 if expired else 200)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,197 @@
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawn } from "node:child_process";
import puppeteer from "puppeteer-core";
const PROTOTYPE = new URL("..", import.meta.url);
const FIREFOX = process.env.FIREFOX_PATH ?? "/usr/bin/firefox";
const SERVER_PROGRAM = String.raw`
import json, signal, sys, threading
from pathlib import Path
from web_library import WebLibrary
from workspace_server import Handler, WorkspaceServer
root = Path(sys.argv[1])
bridge = Path(sys.argv[2])
library = WebLibrary(root, bridge)
inactive = root / ".inactive"
inactive.mkdir(mode=0o700, exist_ok=True)
# CONTRACT: expiration réelle et courte, injectée uniquement dans ce processus
# de test. Aucune route ne permet de modifier l'horloge ou le TTL.
server = WorkspaceServer(("127.0.0.1", 0), Handler, workspace=inactive,
bridge=bridge, bootstrap="session-browser-SPECIMEN", library=library,
instance_id="session-browser-SPECIMEN", config_id="session-browser-SPECIMEN",
session_ttl_seconds=5)
print(json.dumps({"origin": server.origin,
"code": "session-browser-SPECIMEN"}), flush=True)
signal.signal(signal.SIGTERM,
lambda _signum, _frame: threading.Thread(target=server.shutdown,
daemon=True).start())
try:
server.serve_forever()
finally:
server.server_close()
`;
function startServer(library, runtime, state) {
return spawn("python3", ["-c", SERVER_PROGRAM, library,
"../../tools/local-jobs"], {
cwd: PROTOTYPE,
env: { ...process.env, PYTHONUNBUFFERED: "1", XDG_RUNTIME_DIR: runtime,
XDG_STATE_HOME: state },
stdio: ["ignore", "pipe", "pipe"],
});
}
function ready(server) {
return new Promise((resolve, reject) => {
let output = "";
let errors = "";
const timer = setTimeout(() => reject(new Error(
`démarrage Web expiré: ${errors}`)), 10000);
server.stderr.on("data", (chunk) => { errors += chunk; });
server.stdout.on("data", (chunk) => {
output += chunk;
const newline = output.indexOf("\n");
if (newline < 0) return;
clearTimeout(timer);
resolve(JSON.parse(output.slice(0, newline)));
});
server.once("exit", (code) => reject(new Error(
`serveur arrêté (${code}): ${errors}`)));
});
}
async function stopServer(server) {
if (!server || server.exitCode !== null) return;
const exited = new Promise((resolve) => server.once("exit", resolve));
server.kill("SIGTERM");
await exited;
}
async function login(page, session) {
await page.goto(session.origin, { waitUntil: "domcontentloaded" });
await page.waitForSelector("#login-form");
await page.type("#bootstrap-code", session.code);
await Promise.all([
page.waitForNavigation({ waitUntil: "domcontentloaded" }),
page.click("#login-form button"),
]);
await page.waitForSelector("#library-home:not([hidden])");
}
async function createAndOpenSpecimen(page) {
const title = "Enquête session Firefox SPECIMEN";
await page.type("#workspace-title", title);
await page.click("#workspace-create-form button");
await page.waitForFunction((expected) => [...document.querySelectorAll(
".workspace-card h3")].some((heading) => heading.textContent === expected), {}, title);
await page.click(".workspace-card [data-workspace-id]");
await page.waitForFunction(() => document.querySelector("#connection")
?.textContent.includes("Ouverte"), { timeout: 15000 });
return page.evaluate(() => window.__LABFY_TEST__.getState().workspaceId);
}
function delay(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
const library = await mkdtemp(join(tmpdir(), "labfy-session-library-SPECIMEN-"));
const profile = await mkdtemp(join(tmpdir(), "labfy-session-firefox-SPECIMEN-"));
const runtime = await mkdtemp(join(tmpdir(), "labfy-session-runtime-SPECIMEN-"));
const state = await mkdtemp(join(tmpdir(), "labfy-session-state-SPECIMEN-"));
let browser;
let server;
try {
server = startServer(library, runtime, state);
const session = await ready(server);
assert.notEqual(new URL(session.origin).port, "8081");
assert.notEqual(new URL(session.origin).port, "8080");
// CONTRACT: le serveur peut être déjà ancien quand Firefox soumet son premier
// formulaire. La connexion réussie doit alors commencer une nouvelle durée.
await delay(5500);
browser = await puppeteer.launch({ browser: "firefox", executablePath: FIREFOX,
headless: true, userDataDir: profile, args: ["--no-remote"] });
const page = await browser.newPage();
const pageErrors = [];
const unauthorized = [];
page.on("pageerror", (error) => pageErrors.push(String(error)));
page.on("response", (response) => {
if (response.status() === 401 && new URL(response.url()).pathname.startsWith("/api/"))
unauthorized.push(response.url());
});
await login(page, session);
const workspaceId = await createAndOpenSpecimen(page);
assert.ok(workspaceId);
await page.$eval("#report-comment", (input) => {
input.value = "Brouillon conservé après expiration SPECIMEN";
input.dispatchEvent(new Event("input", { bubbles: true }));
});
const before = await page.evaluate(async () => {
const response = await fetch("/api/v1/session", { cache: "no-store" });
return response.json();
});
const oldCookie = (await page.cookies()).find((cookie) =>
cookie.name.startsWith("labfy_session_"));
assert.ok(oldCookie);
// WHY: un polling réel doit découvrir l'expiration et arrêter ses trois
// pairs ; le test n'appelle aucune route d'administration.
await page.waitForSelector("#session-reconnect", { timeout: 10000 });
const expiredState = await page.evaluate(() => window.__LABFY_TEST__.getState());
assert.equal(expiredState.sessionExpired, true);
assert.equal(expiredState.refreshTimerCount, 0);
assert.equal(expiredState.hasCsrf, false);
assert.equal(expiredState.workspaceId, null);
assert.equal(await page.$eval("#workbench-shell", (item) => item.hidden), true);
assert.match(await page.$eval("#library-connection", (item) => item.textContent),
/travail en cours a été arrêté/i);
assert.equal(await page.$$eval("#session-reconnect", (items) => items.length), 1);
const requestCount = unauthorized.length;
await new Promise((resolve) => setTimeout(resolve, 1400));
assert.equal(unauthorized.length, requestCount,
"aucune rafale API après la première transition d'expiration");
assert.equal(requestCount, 1, "un seul polling observe le 401 avant l'annulation");
assert.equal(await page.evaluate(() => Object.keys(sessionStorage)
.some((key) => key.startsWith("labfy-report-draft:"))), true);
await page.click("#session-reconnect");
await page.waitForSelector("#login-form");
await page.type("#bootstrap-code", session.code);
await Promise.all([
page.waitForNavigation({ waitUntil: "domcontentloaded" }),
page.click("#login-form button"),
]);
await page.waitForSelector("#library-home:not([hidden])");
const renewed = await page.evaluate(async () => {
const response = await fetch("/api/v1/session", { cache: "no-store" });
return response.json();
});
assert.notEqual(renewed.csrf, before.csrf);
assert.equal(await page.$eval(".workspace-card h3", (item) => item.textContent),
"Enquête session Firefox SPECIMEN");
await page.click(`.workspace-card [data-workspace-id="${workspaceId}"]`);
await page.waitForFunction((expected) =>
window.__LABFY_TEST__.getState().workspaceId === expected, {}, workspaceId);
const staleHeaders = { Cookie: `${oldCookie.name}=${oldCookie.value}` };
assert.equal((await fetch(`${session.origin}/api/v1/session`,
{ headers: staleHeaders })).status, 401);
assert.equal((await fetch(`${session.origin}/api/v1/queue/pause`, {
method: "POST",
headers: { ...staleHeaders, Origin: session.origin,
"Content-Type": "application/json", "X-Labfy-CSRF": before.csrf },
body: "{}",
})).status, 403);
assert.deepEqual(pageErrors, []);
console.log("PASS Firefox session : expiration réelle, arrêt unique, reconnexion, " +
"contexte SPECIMEN et rotation cookie/CSRF");
} finally {
if (browser) await browser.close();
await stopServer(server);
await Promise.all([library, profile, runtime, state].map((path) =>
rm(path, { recursive: true, force: true })));
}

View file

@ -0,0 +1,320 @@
"""Régressions synthétiques du lanceur 8081 et de la bibliothèque Web."""
from __future__ import annotations
import http.client
import json
import os
import socket
import subprocess
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
from unittest import mock
WEB = Path(__file__).resolve().parents[1]
ROOT = WEB.parents[1]
sys.path.insert(0, str(WEB))
import web_app # noqa: E402
from web_library import LibraryError, WebLibrary # noqa: E402
from workspace_server import Handler, WorkspaceServer # noqa: E402
class SyntheticEnvironment(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory(prefix="labfy-web-SPECIMEN-")
self.root = Path(self.temporary.name)
self.runtime = self.root / "runtime"
self.state = self.root / "state"
self.runtime.mkdir(mode=0o700)
self.state.mkdir(mode=0o700)
self.environment = {**os.environ, "XDG_RUNTIME_DIR": str(self.runtime),
"XDG_STATE_HOME": str(self.state)}
self.bridge = self.root / "fake-local-jobs"
self.bridge.write_text("""#!/usr/bin/env python3
import json,sys
from pathlib import Path
args=sys.argv[1:]
workspace=Path(args[args.index('--workspace')+1])
if args[0]=='create-workspace':
title=args[args.index('--title')+1]
(workspace/'.labfy/runtime').mkdir(parents=True,exist_ok=True)
(workspace/'Enquete.sqlite').write_bytes(b'SPECIMEN')
manifest={'contract':'labfy.local_workspace.v1','investigation_id':'00000000-0000-4000-8000-000000000001','title':title,'mode':'local_experimental'}
(workspace/'.labfy/runtime/workspace.json').write_text(json.dumps(manifest))
elif args[0]=='export':
pass
else:
print('commande SPECIMEN inconnue',file=sys.stderr)
sys.exit(2)
""", encoding="utf-8")
self.bridge.chmod(0o700)
def tearDown(self):
# Les tests de cycle doivent toujours arrêter leur propre serveur.
subprocess.run([sys.executable, str(WEB / "web_app.py"), "stop"],
env=self.environment, capture_output=True, timeout=8)
self.temporary.cleanup()
class LauncherTest(SyntheticEnvironment):
def command(self, *arguments, timeout=8):
return subprocess.run([sys.executable, str(WEB / "web_app.py"), *arguments],
cwd=self.root, env=self.environment, text=True, capture_output=True,
timeout=timeout, check=False)
def test_default_and_invalid_ports(self):
parser = web_app.build_parser()
arguments = parser.parse_args(["serve", "--workspace", str(self.root / "w")])
self.assertEqual(arguments.port, 8081)
for port in ("-1", "65536", "abc"):
with self.assertRaises(SystemExit):
parser.parse_args(["serve", "--workspace", str(self.root / "w"),
"--port", port])
self.assertEqual(parser.parse_args(["serve", "--workspace",
str(self.root / "w"), "--port", "0"]).port, 0)
def test_xdg_start_status_code_double_start_and_stop(self):
workspace = self.root / "workspace"
started = self.command("start", "--workspace", str(workspace), "--port", "0",
"--bridge", str(self.bridge))
self.assertEqual(started.returncode, 0, started.stderr)
self.assertIn("Code de session éphémère : ", started.stdout)
bootstrap = started.stdout.split("Code de session éphémère : ", 1)[1].strip()
instance = (self.runtime / web_app.APP_DIRECTORY / "instance.json")
self.assertTrue(instance.is_file())
self.assertNotIn(bootstrap, instance.read_text(encoding="utf-8"))
self.assertEqual(instance.stat().st_mode & 0o777, 0o600)
self.assertEqual((self.runtime / web_app.APP_DIRECTORY).stat().st_mode & 0o777,
0o700)
config = self.state / web_app.APP_DIRECTORY / "config.json"
self.assertEqual(config.stat().st_mode & 0o777, 0o600)
self.assertNotIn(bootstrap, config.read_text(encoding="utf-8"))
status = self.command("status")
self.assertEqual(status.returncode, 0, status.stderr)
self.assertIn("actif", status.stdout)
code = self.command("code")
self.assertEqual(code.returncode, 0, code.stderr)
self.assertEqual(code.stdout.strip(), bootstrap)
duplicate = self.command("start", "--workspace", str(workspace), "--port", "0",
"--bridge", str(self.bridge))
self.assertNotEqual(duplicate.returncode, 0)
self.assertIn("déjà active", duplicate.stderr)
stopped = self.command("stop")
self.assertEqual(stopped.returncode, 0, stopped.stderr)
self.assertFalse(instance.exists())
def test_occupied_port_does_not_disturb_witness(self):
witness = socket.socket()
witness.bind(("127.0.0.1", 0))
witness.listen()
port = witness.getsockname()[1]
result = self.command("serve", "--workspace", str(self.root / "occupied"),
"--port", str(port), "--bridge", str(self.bridge))
self.assertNotEqual(result.returncode, 0)
witness.settimeout(0.2)
probe = socket.create_connection(("127.0.0.1", port), timeout=1)
accepted, _ = witness.accept()
probe.close()
accepted.close()
witness.close()
def test_stale_state_never_signals_unconfirmed_pid(self):
with mock.patch.dict(os.environ, self.environment, clear=True):
files = web_app.InstanceFiles()
web_app._atomic_json(files.instance, {
"pid": os.getpid(), "proc_start": web_app._proc_start(os.getpid()),
"port": 1, "instance_id": "foreign", "config_id": "foreign"})
with mock.patch.object(web_app.os, "kill") as kill:
self.assertEqual(web_app.stop(None), 1)
kill.assert_not_called()
class LibraryTest(SyntheticEnvironment):
def setUp(self):
super().setUp()
self.library = WebLibrary(self.root / "library", self.bridge)
def _creation_intent_workspace_id(self):
intent = json.loads(self.library.creation_intent_path.read_text(encoding="utf-8"))
return intent["workspace_id"]
def _assert_resumed_once(self, expected_workspace_id, response):
self.assertEqual(response["workspace_id"], expected_workspace_id)
self.assertEqual(len(list(self.library.workspaces.iterdir())), 1)
self.assertEqual(len(self.library.snapshot()["entries"]), 1)
self.assertFalse(self.library.creation_intent_path.exists())
def test_empty_create_replay_conflict_and_symlink_rejection(self):
self.assertEqual(self.library.snapshot()["entries"], [])
created = self.library.create("Enquête SPECIMEN", "intent-1")
self.assertFalse(created["replayed"])
replay = self.library.create("Enquête SPECIMEN", "intent-1")
self.assertTrue(replay["replayed"])
self.assertEqual(replay["workspace_id"], created["workspace_id"])
with self.assertRaisesRegex(LibraryError, "autre titre"):
self.library.create("Titre contradictoire", "intent-1")
workspace = self.library.workspaces / created["workspace_id"]
moved = self.root / "moved-workspace"
workspace.rename(moved)
workspace.symlink_to(moved, target_is_directory=True)
with self.assertRaisesRegex(LibraryError, "invalide"):
self.library.snapshot()
def test_create_resumes_same_workspace_after_bridge_interruption(self):
run = subprocess.run
calls = 0
def interrupt_after_bridge(*arguments, **keywords):
nonlocal calls
calls += 1
result = run(*arguments, **keywords)
raise OSError("interruption SPECIMEN après bridge")
with mock.patch("web_library.subprocess.run", side_effect=interrupt_after_bridge):
with self.assertRaisesRegex(OSError, "après bridge"):
self.library.create("Reprise bridge SPECIMEN", "resume-bridge")
workspace_id = self._creation_intent_workspace_id()
self.library = WebLibrary(self.library.root, self.bridge)
resumed = self.library.create("Reprise bridge SPECIMEN", "resume-bridge")
self.assertEqual(calls, 1)
self._assert_resumed_once(workspace_id, resumed)
def test_create_resumes_same_workspace_after_validation_interruption(self):
validate = WebLibrary._validate_created_workspace
validations = 0
def interrupt_after_validation(workspace):
nonlocal validations
validate(workspace)
validations += 1
if validations == 1:
raise OSError("interruption SPECIMEN après validation")
with mock.patch.object(WebLibrary, "_validate_created_workspace",
side_effect=interrupt_after_validation):
with self.assertRaisesRegex(OSError, "après validation"):
self.library.create("Reprise validation SPECIMEN", "resume-validation")
workspace_id = self._creation_intent_workspace_id()
self.library = WebLibrary(self.library.root, self.bridge)
with mock.patch("web_library.subprocess.run") as bridge:
resumed = self.library.create(
"Reprise validation SPECIMEN", "resume-validation")
bridge.assert_not_called()
self._assert_resumed_once(workspace_id, resumed)
def test_create_republishes_same_workspace_after_registry_write_failure(self):
with mock.patch.object(self.library, "_write_registry",
side_effect=OSError("écriture registre SPECIMEN")):
with self.assertRaisesRegex(OSError, "écriture registre"):
self.library.create("Reprise registre SPECIMEN", "resume-registry")
workspace_id = self._creation_intent_workspace_id()
self.library = WebLibrary(self.library.root, self.bridge)
with mock.patch("web_library.subprocess.run") as bridge:
resumed = self.library.create("Reprise registre SPECIMEN", "resume-registry")
bridge.assert_not_called()
self._assert_resumed_once(workspace_id, resumed)
def test_create_finishes_after_late_registry_publication_failure(self):
write_registry = self.library._write_registry
def interrupt_after_publication(registry):
write_registry(registry)
raise OSError("interruption SPECIMEN après publication")
with mock.patch.object(self.library, "_write_registry",
side_effect=interrupt_after_publication):
with self.assertRaisesRegex(OSError, "après publication"):
self.library.create("Publication SPECIMEN", "resume-published")
workspace_id = self._creation_intent_workspace_id()
self.library = WebLibrary(self.library.root, self.bridge)
with mock.patch("web_library.subprocess.run") as bridge:
resumed = self.library.create("Publication SPECIMEN", "resume-published")
bridge.assert_not_called()
self.assertTrue(resumed["replayed"])
self._assert_resumed_once(workspace_id, resumed)
def test_authenticated_routes_generation_stale_and_busy(self):
server = WorkspaceServer(("127.0.0.1", 0), Handler,
workspace=self.library.root / ".inactive", bridge=self.bridge,
bootstrap="SPECIMEN-bootstrap", library=self.library,
instance_id="SPECIMEN-instance", config_id="SPECIMEN-config")
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
cookie = None
def request(method, path, body=None, *, authenticated=True, csrf=True):
nonlocal cookie
connection = http.client.HTTPConnection("127.0.0.1", server.server_port,
timeout=3)
headers = {"Host": server.authority}
if method == "POST":
headers.update({"Origin": server.origin, "Content-Type": "application/json"})
if csrf: headers["X-Labfy-CSRF"] = server.csrf
if authenticated and cookie: headers["Cookie"] = cookie
payload = json.dumps(body) if body is not None else None
connection.request(method, path, body=payload, headers=headers)
response = connection.getresponse()
data = response.read()
if response.getheader("Set-Cookie"):
cookie = response.getheader("Set-Cookie").split(";", 1)[0]
result = response.status, json.loads(data)
connection.close()
return result
try:
self.assertEqual(request("GET", "/api/v1/library", authenticated=False)[0], 401)
self.assertEqual(request("POST", "/api/v1/session",
{"bootstrap_code": "SPECIMEN-bootstrap"}, authenticated=False,
csrf=False)[0], 200)
self.assertEqual(request("POST", "/api/v1/library/workspaces",
{"title": "Sans CSRF", "idempotency_key": "x"}, csrf=False)[0], 403)
status, empty = request("GET", "/api/v1/library")
self.assertEqual(status, 200)
self.assertEqual(empty["generation"], 0)
status, first = request("POST", "/api/v1/library/workspaces",
{"title": "Première SPECIMEN", "idempotency_key": "create-1"})
self.assertEqual(status, 201, first)
time.sleep(0.04)
status, replay = request("POST", "/api/v1/library/workspaces",
{"title": "Première SPECIMEN", "idempotency_key": "create-1"})
self.assertEqual(status, 200)
self.assertTrue(replay["replayed"])
time.sleep(0.04)
status, second = request("POST", "/api/v1/library/workspaces",
{"title": "Seconde SPECIMEN", "idempotency_key": "create-2"})
self.assertEqual(status, 201, second)
time.sleep(0.04)
stale = request("POST", "/api/v1/library/open", {
"workspace_id": first["workspace_id"], "expected_generation": 1})
self.assertEqual(stale[0], 409)
time.sleep(0.04)
opened = request("POST", "/api/v1/library/open", {
"workspace_id": first["workspace_id"],
"expected_generation": second["generation"]})
self.assertEqual(opened[0], 200, opened)
time.sleep(0.04)
busy = request("POST", "/api/v1/library/open", {
"workspace_id": second["workspace_id"],
"expected_generation": second["generation"]})
self.assertEqual(busy[0], 409)
session = request("GET", "/api/v1/session")[1]
self.assertTrue(session["library_mode"])
self.assertEqual(session["active_workspace_id"], first["workspace_id"])
self.assertEqual(session["generation"], second["generation"])
finally:
server.shutdown()
server.server_close()
thread.join(3)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,369 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawn, spawnSync } from "node:child_process";
import puppeteer from "puppeteer-core";
const PROTOTYPE = new URL("..", import.meta.url);
const FIREFOX = process.env.FIREFOX_PATH ?? "/usr/bin/firefox";
function startServer(library, runtime, state, port = 0) {
return spawn("python3", ["web_app.py", "serve", "--library", library,
"--bridge", "../../tools/local-jobs", "--port", String(port)], {
cwd: PROTOTYPE,
env: { ...process.env, PYTHONUNBUFFERED: "1", XDG_RUNTIME_DIR: runtime,
XDG_STATE_HOME: state },
stdio: ["ignore", "pipe", "pipe"],
});
}
function ready(server) {
return new Promise((resolve, reject) => {
let output = "";
const timer = setTimeout(() => reject(new Error("démarrage Web expiré")), 10000);
server.stdout.on("data", (chunk) => {
output += chunk;
const origin = output.match(/Labfy Web : (http:\/\/127\.0\.0\.1:\d+)\//);
const code = output.match(/Code de session éphémère : ([^\s]+)/);
if (origin && code) {
clearTimeout(timer);
resolve({ origin: origin[1], code: code[1] });
}
});
server.once("exit", (code) => reject(new Error(`serveur arrêté (${code})`)));
});
}
async function stopServer(server) {
if (!server || server.exitCode !== null) return;
const exited = new Promise((resolve) => server.once("exit", resolve));
server.kill("SIGTERM");
await exited;
}
async function login(page, session) {
try {
await page.evaluate((origin) => window.location.assign(origin), session.origin);
} catch (error) {
if (!String(error).includes("Execution context was destroyed")) throw error;
}
await page.waitForSelector("#login-form");
await page.type("#bootstrap-code", session.code);
await page.click("#login-form button");
await page.waitForSelector("#library-home:not([hidden])");
assert.equal(page.url().includes(session.code), false);
assert.equal((await page.content()).includes(session.code), false);
}
async function createWorkspace(page, title) {
await page.type("#workspace-title", title);
await page.click("#workspace-create-form button");
await page.waitForFunction((expectedTitle) => [...document.querySelectorAll(".workspace-card")]
.some((card) => card.querySelector("h3")?.textContent === expectedTitle), {}, title);
return page.$$eval(".workspace-card", (cards, expectedTitle) => {
const card = cards.find((item) => item.querySelector("h3")?.textContent === expectedTitle);
return card?.querySelector("[data-workspace-id]")?.dataset.workspaceId ?? null;
}, title);
}
async function openWorkspace(page, workspaceId) {
await page.click(`[data-workspace-id="${workspaceId}"]`);
await page.waitForSelector("#workbench-shell:not([hidden])");
try {
await page.waitForFunction(() => {
const connection = document.querySelector("#connection")?.textContent ?? "";
return connection.includes("Ouverte") || connection.includes("Poste local synchronisé");
}, { timeout: 15000 });
} catch (error) {
const connection = await page.$eval("#connection", (item) => item.textContent);
throw new Error(`ouverture ${workspaceId} incomplète : ${connection}`, { cause: error });
}
}
async function waitForDownload(path) {
let size = -1;
let stable = 0;
for (let attempt = 0; attempt < 120 && stable < 3; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 50));
try {
const current = (await stat(path)).size;
stable = current > 0 && current === size ? stable + 1 : 0;
size = current;
} catch {
stable = 0;
}
}
assert.ok(stable >= 3, `téléchargement Firefox terminé ${path}`);
return size;
}
const library = await mkdtemp(join(tmpdir(), "labfy-web-library-"));
const profile = await mkdtemp(join(tmpdir(), "labfy-web-firefox-"));
const runtime = await mkdtemp(join(tmpdir(), "labfy-web-runtime-"));
const state = await mkdtemp(join(tmpdir(), "labfy-web-state-"));
const fixtures = await mkdtemp(join(tmpdir(), "labfy-web-SPECIMEN-"));
const downloads = join(fixtures, "downloads");
await mkdir(downloads, { mode: 0o700 });
const specimenEml = join(fixtures, "courrier rapport SPECIMEN.eml");
await writeFile(specimenEml,
"From: Source SPECIMEN <source@example.test>\r\n" +
"To: Pivot SPECIMEN <pivot@example.test>\r\n" +
"Message-ID: <web-app-report@example.test>\r\n" +
"Subject: Rapport navigateur A\r\n\r\n" +
"Contenu synthétique pour le parcours A.\r\n");
let browser;
let server;
try {
server = startServer(library, runtime, state);
const session = await ready(server);
const assignedPort = Number.parseInt(new URL(session.origin).port, 10);
browser = await puppeteer.launch({ browser: "firefox", executablePath: FIREFOX,
headless: true, userDataDir: profile, args: ["--no-remote"],
extraPrefsFirefox: { "browser.download.folderList": 2,
"browser.download.dir": downloads, "browser.download.useDownloadDir": true,
"browser.helperApps.neverAsk.saveToDisk":
"text/html,application/json,application/pdf,text/plain" } });
const page = await browser.newPage();
const errors = [];
page.on("pageerror", (error) => errors.push(String(error)));
await page.setViewport({ width: 700, height: 900 });
await login(page, session);
assert.equal(await page.$eval("#workspace-list", (item) => item.children.length), 0);
assert.match(await page.$eval("#library-empty", (item) => item.textContent), /Aucune enquête/);
const workspaceATitle = "Enquête A navigateur SPECIMEN";
const workspaceAId = await createWorkspace(page, workspaceATitle);
assert.ok(workspaceAId);
assert.equal(await page.$eval("#workbench-shell", (item) => item.hidden), true);
await new Promise((resolve) => setTimeout(resolve, 60));
await openWorkspace(page, workspaceAId);
assert.equal(await page.$eval("#investigation-title", (item) => item.textContent),
workspaceATitle);
assert.equal(await page.$eval("#mode-badge", (item) => item.textContent.includes("SPECIMEN")), false);
await page.click("#open-import");
const importInput = await page.$("#import-files");
await importInput.uploadFile(specimenEml);
await page.waitForFunction(() => {
const items = [...document.querySelectorAll("#import-list li")];
return items.length === 1 && items.every((item) => item.textContent.includes("préparé"));
}, { timeout: 20000 });
await page.click("#import-confirm");
await page.waitForFunction(() => document.querySelectorAll(".node").length >= 1,
{ timeout: 20000 });
await page.click('[data-work-panel="next-panel"]');
await page.waitForFunction(() =>
document.querySelectorAll("#planner-list input:not(:disabled)").length >= 1,
{ timeout: 15000 });
await page.$$eval("#planner-list input:not(:disabled)", (inputs) => {
inputs.forEach((input) => input.click());
});
await page.select("#planner-profile", "LOCAL_PRUDENT");
await page.click("#planner-launch");
await page.waitForFunction(() => {
const jobs = [...document.querySelectorAll("#jobs-list li")];
return jobs.length >= 1 && jobs.every((job) => job.dataset.jobState === "COMPLETED");
}, { timeout: 45000 });
await page.evaluate(() => {
const candidate = [...document.querySelectorAll("#object-list button")]
.find((button) => button.textContent.startsWith("email —"));
if (!candidate) throw new Error("preuve e-mail SPECIMEN absente");
candidate.click();
});
await page.click('[data-panel="actions-panel"]');
await page.evaluate(() => {
const button = [...document.querySelectorAll("#actions button")]
.find((item) => item.textContent === "Ouvrir la preuve");
if (!button) throw new Error("action Ouvrir la preuve absente");
button.click();
});
await page.waitForSelector("#evidence-dialog[open] #observation-list li", { timeout: 15000 });
await page.type("#observation-list li input[placeholder='Motif obligatoire']",
"Revue rapport A SPECIMEN");
await page.evaluate(() => {
const button = [...document.querySelectorAll("#observation-list li:first-child button")]
.find((item) => item.textContent === "Confirmer");
if (!button) throw new Error("action Confirmer absente");
button.click();
});
await page.waitForFunction(() =>
document.querySelector("#evidence-status")?.textContent.includes("journalisée"),
{ timeout: 15000 });
await page.click("#evidence-dialog .dialog-close button");
await page.click('[data-report-action="toggle"]');
await page.click('[data-work-panel="reports-panel"]');
await page.click("#report-name", { clickCount: 3 });
await page.type("#report-name", "Rapport enquête A SPECIMEN");
await page.click("#report-preview");
await page.waitForFunction(() =>
document.querySelector("#report-preview-content")?.textContent.includes("Coupe"),
{ timeout: 15000 });
await page.click("#report-generate");
await page.waitForFunction(() => document.querySelectorAll("#report-status a").length === 5,
{ timeout: 30000 });
const reportHref = await page.$eval('#report-status a[href$="report.json"]',
(link) => link.getAttribute("href"));
const reportId = reportHref.split("/")[4];
const artifactNames = ["report.json", "report.html", "report.pdf", "manifest.json",
"NOTICE.txt"];
const receivedSizes = new Map();
for (const name of artifactNames) {
await page.$eval(`#report-status a[href$="${name}"]`, (link) => link.click());
receivedSizes.set(name, await waitForDownload(join(downloads, name)));
}
const manifestBytes = await readFile(join(downloads, "manifest.json"));
const manifest = JSON.parse(manifestBytes);
assert.equal(manifest.contract, "labfy.report_manifest.v1");
assert.equal(manifest.report_id, reportId);
assert.deepEqual(new Set(manifest.files.map((entry) => entry.path)),
new Set(artifactNames.filter((name) => name !== "manifest.json")));
for (const entry of manifest.files) {
const bytes = await readFile(join(downloads, entry.path));
assert.equal(bytes.length, entry.size, `taille manifestée ${entry.path}`);
assert.equal(receivedSizes.get(entry.path), entry.size, `taille reçue ${entry.path}`);
assert.equal(createHash("sha256").update(bytes).digest("hex"), entry.sha256,
`SHA-256 ${entry.path}`);
}
assert.equal(receivedSizes.get("manifest.json"), manifestBytes.length,
"taille reçue manifest.json");
const reportHtml = (await readFile(join(downloads, "report.html"))).toString("utf8");
assert.doesNotMatch(reportHtml, /<(?:script|iframe|frame|object|embed|link)\b/i);
assert.doesNotMatch(reportHtml, /\b(?:src|href|action)\s*=\s*["'](?:https?:|\/\/)/i);
assert.doesNotMatch(reportHtml, /url\s*\(/i);
const offlinePage = await browser.newPage();
await offlinePage.goto(`file://${join(downloads, "report.html")}`,
{ waitUntil: "domcontentloaded" });
assert.match(await offlinePage.$eval("body", (body) => body.textContent),
/Rapport enquête A SPECIMEN/);
assert.equal(await offlinePage.$$eval(
"script,iframe,frame,object,embed,link,img,audio,video,source",
(nodes) => nodes.length), 0);
await offlinePage.close();
const pdfText = spawnSync("pdftotext", [join(downloads, "report.pdf"), "-"],
{ encoding: "utf8" });
assert.equal(pdfText.status, 0, pdfText.stderr);
assert.match(pdfText.stdout.split(/\s+/).join(" "), /Rapport enquête A SPECIMEN/);
await page.$eval("#report-name", (input) => {
input.value = "Brouillon rapport A SPECIMEN";
input.dispatchEvent(new Event("input", { bubbles: true }));
});
let draftKeys = await page.evaluate(() => Object.keys(sessionStorage)
.filter((key) => key.startsWith("labfy-report-draft:")));
assert.equal(draftKeys.length, 1);
assert.ok(draftKeys[0].includes(workspaceAId));
// INVARIANT: une actualisation devenue tardive après « Accueil » ne doit pas
// restaurer le workbench ni altérer la bibliothèque rendue ensuite.
await page.setRequestInterception(true);
let releaseSnapshot;
const delayedSnapshot = new Promise((resolve) => { releaseSnapshot = resolve; });
let markSnapshotContinued;
const snapshotContinued = new Promise((resolve) => { markSnapshotContinued = resolve; });
let held = false;
const holdSnapshotRequest = async (request) => {
if (!held && new URL(request.url()).pathname === "/api/v1/snapshot") {
held = true;
await delayedSnapshot;
await request.continue().catch(() => {});
markSnapshotContinued();
return;
}
await request.continue().catch(() => {});
};
page.on("request", holdSnapshotRequest);
await page.waitForFunction(() => window.__LABFY_TEST__.getState().workspaceId !== null);
for (let attempt = 0; attempt < 100 && !held; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
assert.equal(held, true);
await page.click("#home-button");
await page.waitForSelector("#library-home:not([hidden])");
releaseSnapshot();
await snapshotContinued;
await new Promise((resolve) => setTimeout(resolve, 150));
assert.equal(await page.$eval("#workbench-shell", (item) => item.hidden), true);
assert.match(await page.$eval(".workspace-card p", (item) => item.textContent), /Ouverte/);
await page.setRequestInterception(false);
page.off("request", holdSnapshotRequest);
// Le port initial est attribué par le système. Sa réutilisation conserve le
// même origin et permet de vérifier les brouillons session après redémarrage.
await stopServer(server);
server = startServer(library, runtime, state, assignedPort);
const restartedForB = await ready(server);
assert.equal(restartedForB.origin, session.origin);
await login(page, restartedForB);
assert.equal(await page.$eval("#workspace-list", (item) => item.children.length), 1);
assert.equal(await page.$eval(".workspace-card h3", (item) => item.textContent), workspaceATitle);
assert.match(await page.$eval(".workspace-card p", (item) => item.textContent), /Prête à ouvrir/);
const workspaceBTitle = "Enquête B navigateur SPECIMEN";
const workspaceBId = await createWorkspace(page, workspaceBTitle);
assert.ok(workspaceBId);
assert.notEqual(workspaceBId, workspaceAId);
assert.equal(await page.$eval("#workspace-list", (item) => item.children.length), 2);
await openWorkspace(page, workspaceBId);
assert.equal(await page.$eval("#investigation-title", (item) => item.textContent),
workspaceBTitle);
assert.equal(await page.$eval("#report-name", (item) => item.value), "Rapport d’enquête");
draftKeys = await page.evaluate(() => Object.keys(sessionStorage)
.filter((key) => key.startsWith("labfy-report-draft:")));
assert.equal(draftKeys.length, 1);
assert.ok(draftKeys[0].includes(workspaceAId));
assert.equal(draftKeys[0].includes(workspaceBId), false);
await page.$eval("#report-name", (input) => {
input.value = "Brouillon rapport B SPECIMEN";
input.dispatchEvent(new Event("input", { bubbles: true }));
});
draftKeys = await page.evaluate(() => Object.keys(sessionStorage)
.filter((key) => key.startsWith("labfy-report-draft:")));
assert.equal(draftKeys.length, 2);
assert.equal(draftKeys.some((key) => key.includes(workspaceAId)), true);
assert.equal(draftKeys.some((key) => key.includes(workspaceBId)), true);
await page.click("#home-button");
await page.waitForSelector("#library-home:not([hidden])");
await stopServer(server);
server = startServer(library, runtime, state, assignedPort);
const restartedForA = await ready(server);
assert.equal(restartedForA.origin, session.origin);
await login(page, restartedForA);
assert.equal(await page.$eval("#workspace-list", (item) => item.children.length), 2);
await openWorkspace(page, workspaceAId);
assert.equal(await page.$eval("#investigation-title", (item) => item.textContent),
workspaceATitle);
assert.equal(await page.evaluate(() => window.__LABFY_TEST__.getState().workspaceId),
workspaceAId);
// La création de B incrémente la génération de bibliothèque : le brouillon
// A de la génération précédente reste stocké mais ne peut contaminer A ou B.
assert.equal(await page.$eval("#report-name", (item) => item.value),
"Rapport d’enquête");
assert.notEqual(await page.$eval("#report-name", (item) => item.value),
"Brouillon rapport B SPECIMEN");
draftKeys = await page.evaluate(() => Object.keys(sessionStorage)
.filter((key) => key.startsWith("labfy-report-draft:")));
assert.equal(draftKeys.length, 2);
assert.equal(draftKeys.some((key) => key.includes(workspaceAId)), true);
assert.equal(draftKeys.some((key) => key.includes(workspaceBId)), true);
await page.evaluate(() => { document.documentElement.style.zoom = "200%"; });
assert.equal(await page.evaluate(() =>
document.documentElement.scrollWidth > document.documentElement.clientWidth), false);
assert.deepEqual(errors, []);
console.log("PASS navigateur Web : A importée/analysée/revue avec rapport vérifié, " +
"bibliothèque A/B, brouillons isolés et deux redémarrages");
} finally {
if (browser) await browser.close();
await stopServer(server);
await Promise.all([library, profile, runtime, state, fixtures].map((path) =>
rm(path, { recursive: true, force: true })));
}

View file

@ -68,7 +68,7 @@ try {
await Promise.all([page.waitForNavigation({ waitUntil: "domcontentloaded" }),
page.click("#login-form button")]);
await page.waitForSelector(`[data-id="evidence:${manifest.eml_id}"]`);
assert.match(await page.$eval("#mode-badge", (item) => item.textContent), /Poste J6/);
assert.match(await page.$eval("#mode-badge", (item) => item.textContent), /Poste local/);
await page.click("#queue-pause");
await clickEvidenceCapability(page, manifest.eml_id,

View file

@ -0,0 +1,372 @@
#!/usr/bin/env python3
"""Lanceur local explicite et gestionnaire d'instance du poste Web Labfy."""
from __future__ import annotations
import argparse
import fcntl
import hashlib
import http.client
import json
import os
import select
import secrets
import signal
import subprocess
import sys
import threading
import time
from pathlib import Path
from web_library import WebLibrary
from workspace_server import Handler, WorkspaceServer
DEFAULT_PORT = 8081
APP_DIRECTORY = "labfy-investigation-web"
def valid_port(value):
try:
port = int(value)
except (TypeError, ValueError) as error:
raise argparse.ArgumentTypeError("le port doit être un entier") from error
if not 0 <= port <= 65535:
raise argparse.ArgumentTypeError("le port doit être compris entre 0 et 65535")
return port
def _private_directory(path: Path):
absolute = Path(os.path.abspath(path))
for component in reversed((absolute, *absolute.parents)):
if component.exists() and component.is_symlink():
raise RuntimeError(f"Lien symbolique interdit dans l'état : {component}")
path = absolute
if path.exists() and (path.is_symlink() or not path.is_dir()):
raise RuntimeError(f"Répertoire d'état non sûr : {path}")
path.mkdir(mode=0o700, parents=True, exist_ok=True)
if path.stat().st_uid != os.getuid():
raise RuntimeError(f"Répertoire d'état d'un autre utilisateur : {path}")
os.chmod(path, 0o700)
return path
def runtime_directory(env=None):
env = os.environ if env is None else env
base = env.get("XDG_RUNTIME_DIR")
if base:
root = Path(base)
if root.is_symlink() or not root.is_dir() or root.stat().st_uid != os.getuid():
raise RuntimeError("XDG_RUNTIME_DIR non sûr")
else:
# WHY: le repli est propre à l'UID et non partageable dans /tmp.
root = Path(f"/tmp/labfy-runtime-{os.getuid()}")
return _private_directory(root / APP_DIRECTORY)
def state_directory(env=None):
env = os.environ if env is None else env
base = Path(env.get("XDG_STATE_HOME", Path.home() / ".local" / "state"))
return _private_directory(base / APP_DIRECTORY)
def _atomic_json(path, value):
temporary = path.with_name(f".{path.name}-{secrets.token_hex(8)}.tmp")
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
0o600)
try:
payload = (json.dumps(value, ensure_ascii=False, sort_keys=True,
separators=(",", ":")) + "\n").encode()
os.write(descriptor, payload)
os.fsync(descriptor)
finally:
os.close(descriptor)
temporary.replace(path)
def _read_private_json(path):
if path.is_symlink() or not path.is_file() or path.stat().st_uid != os.getuid():
raise RuntimeError("Fichier d'instance absent ou non sûr")
return json.loads(path.read_text(encoding="utf-8"))
def _proc_start(pid):
try:
# /proc/PID/stat place le nom entre parenthèses ; le champ 22 est le 20e
# champ après la dernière parenthèse.
fields = Path(f"/proc/{pid}/stat").read_text(encoding="ascii").rsplit(")", 1)[1].split()
return fields[19]
except (OSError, IndexError):
return None
def _config(selection, bridge):
mode, path = selection
value = {"mode": mode, "path": str(path.absolute()), "bridge": str(bridge.resolve())}
digest = hashlib.sha256(json.dumps(value, sort_keys=True,
separators=(",", ":")).encode()).hexdigest()
return value, digest
def _health(state, timeout=0.8):
try:
connection = http.client.HTTPConnection("127.0.0.1", state["port"], timeout=timeout)
connection.request("GET", "/healthz", headers={"Host": f"127.0.0.1:{state['port']}"})
response = connection.getresponse()
value = json.loads(response.read())
connection.close()
return (response.status == 200 and value.get("instance_id") == state["instance_id"] and
value.get("config_id") == state["config_id"])
except (OSError, ValueError, KeyError, json.JSONDecodeError):
return False
def _identity_valid(state):
return (isinstance(state, dict) and isinstance(state.get("pid"), int) and
not isinstance(state.get("pid"), bool) and state["pid"] > 1 and
isinstance(state.get("proc_start"), str) and
state.get("proc_start") == _proc_start(state["pid"]) and _health(state))
class InstanceFiles:
def __init__(self):
self.runtime = runtime_directory()
self.state_root = state_directory()
self.instance = self.runtime / "instance.json"
self.secret = self.runtime / "bootstrap.code"
self.config = self.state_root / "config.json"
self.lifecycle = self.runtime / "lifecycle.lock"
self.owner = self.runtime / "instance.lock"
def lifecycle_lock(self):
descriptor = os.open(self.lifecycle, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
fcntl.flock(descriptor, fcntl.LOCK_EX)
return descriptor
def clear_stale(self):
for path in (self.instance, self.secret):
try:
path.unlink()
except FileNotFoundError:
pass
def _selection(args):
if args.library is not None:
return "library", args.library.absolute()
return "workspace", args.workspace.absolute()
def _handshake(fd, value):
if fd is None:
return
with os.fdopen(fd, "w", encoding="utf-8", closefd=True) as output:
output.write(json.dumps(value, ensure_ascii=False, separators=(",", ":")) + "\n")
output.flush()
def serve(args):
files = InstanceFiles()
owner = os.open(files.owner, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
try:
fcntl.flock(owner, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
os.close(owner)
_handshake(args.handshake_fd, {"ok": False, "error": "Une instance est déjà active"})
return 1
selection = _selection(args)
config, config_id = _config(selection, args.bridge)
instance_id = secrets.token_urlsafe(24)
bootstrap = secrets.token_urlsafe(18)
library = None
if selection[0] == "library":
library = WebLibrary(selection[1], args.bridge)
workspace = library.root / ".inactive"
workspace.mkdir(mode=0o700, exist_ok=True)
else:
workspace = selection[1]
workspace.mkdir(mode=0o700, parents=True, exist_ok=True)
try:
server = WorkspaceServer(("127.0.0.1", args.port), Handler,
workspace=workspace, bridge=args.bridge, bootstrap=bootstrap,
library=library, instance_id=instance_id, config_id=config_id)
except OSError as error:
_handshake(args.handshake_fd, {"ok": False,
"error": f"Port indisponible : {error}"})
os.close(owner)
return 1
state = {"contract": "labfy.web_instance.v1", "pid": os.getpid(),
"proc_start": _proc_start(os.getpid()), "instance_id": instance_id,
"config_id": config_id, "port": server.server_port, "config": config}
try:
# CONTRACT: la configuration durable ne contient jamais le secret
# d'amorçage, qui reste exclusivement dans le runtime privé.
_atomic_json(files.config, {"contract": "labfy.web_config.v1",
"config_id": config_id, **config})
_atomic_json(files.instance, state)
descriptor = os.open(files.secret, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW,
0o600)
try:
os.write(descriptor, (bootstrap + "\n").encode())
finally:
os.close(descriptor)
answer = {"ok": True, "origin": server.origin, "bootstrap_code": bootstrap,
"instance_id": instance_id}
_handshake(args.handshake_fd, answer)
if args.handshake_fd is None:
print(f"Labfy Web : {server.origin}/", flush=True)
print(f"Code de session éphémère : {bootstrap}", flush=True)
# INVARIANT: SIGTERM n'arrête que cette instance déjà authentifiée
# par le lanceur ; shutdown s'exécute hors du gestionnaire de signal.
def request_shutdown(_signum, _frame):
threading.Thread(target=server.shutdown, daemon=True).start()
signal.signal(signal.SIGTERM, request_shutdown)
server.serve_forever()
finally:
server.stop_worker()
server.server_close()
try:
current = _read_private_json(files.instance)
if current.get("instance_id") == instance_id:
files.clear_stale()
except (OSError, RuntimeError, json.JSONDecodeError):
pass
os.close(owner)
return 0
def start(args):
files = InstanceFiles()
lifecycle = files.lifecycle_lock()
try:
try:
current = _read_private_json(files.instance)
except (OSError, RuntimeError, json.JSONDecodeError):
current = None
if current is not None and _identity_valid(current):
print(f"Instance déjà active sur http://127.0.0.1:{current['port']}/",
file=sys.stderr)
return 1
files.clear_stale()
read_fd, write_fd = os.pipe()
command = [sys.executable, str(Path(__file__).resolve()), "serve",
"--port", str(args.port), "--bridge", str(args.bridge),
"--handshake-fd", str(write_fd)]
mode, path = _selection(args)
command.extend((f"--{mode}", str(path)))
process = subprocess.Popen(command, stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
start_new_session=True, pass_fds=(write_fd,), close_fds=True)
os.close(write_fd)
ready, _, _ = select.select((read_fd,), (), (), 10)
if not ready:
process.terminate()
process.wait(timeout=3)
os.close(read_fd)
print("Démarrage expiré", file=sys.stderr)
return 1
with os.fdopen(read_fd, "r", encoding="utf-8") as pipe:
line = pipe.readline()
if not line:
print("Le serveur n'a pas publié son état", file=sys.stderr)
return 1
response = json.loads(line)
if not response.get("ok"):
print(response.get("error", "Démarrage refusé"), file=sys.stderr)
return 1
print(f"Labfy Web : {response['origin']}/")
print(f"Code de session éphémère : {response['bootstrap_code']}")
return 0
finally:
os.close(lifecycle)
def status(_args):
files = InstanceFiles()
try:
state = _read_private_json(files.instance)
except (OSError, RuntimeError, json.JSONDecodeError):
print("arrêté")
return 1
if not _identity_valid(state):
print("état périmé")
return 1
print(f"actif pid={state['pid']} origin=http://127.0.0.1:{state['port']}/")
return 0
def stop(_args):
files = InstanceFiles()
lifecycle = files.lifecycle_lock()
try:
try:
state = _read_private_json(files.instance)
except (OSError, RuntimeError, json.JSONDecodeError):
print("Aucune instance active", file=sys.stderr)
return 1
# CONTRACT: ne jamais signaler un PID sur la seule foi d'un fichier d'état.
if not _identity_valid(state):
print("Instance périmée ou identité non confirmée", file=sys.stderr)
return 1
os.kill(state["pid"], signal.SIGTERM)
deadline = time.monotonic() + 5
while time.monotonic() < deadline and _proc_start(state["pid"]) == state["proc_start"]:
time.sleep(0.05)
if _proc_start(state["pid"]) == state["proc_start"]:
print("Arrêt expiré ; aucun signal forcé envoyé", file=sys.stderr)
return 1
files.clear_stale()
print("arrêté")
return 0
finally:
os.close(lifecycle)
def code(_args):
files = InstanceFiles()
try:
state = _read_private_json(files.instance)
if not _identity_valid(state):
raise RuntimeError("Instance inactive")
if files.secret.is_symlink() or not files.secret.is_file():
raise RuntimeError("Code indisponible")
print(files.secret.read_text(encoding="utf-8").strip())
return 0
except (OSError, RuntimeError, json.JSONDecodeError) as error:
print(str(error), file=sys.stderr)
return 1
def build_parser():
parser = argparse.ArgumentParser(description="Poste Web local Labfy")
subparsers = parser.add_subparsers(dest="command", required=True)
repository = Path(__file__).resolve().parents[2]
for name, function in (("serve", serve), ("start", start)):
command = subparsers.add_parser(name)
targets = command.add_mutually_exclusive_group(required=True)
targets.add_argument("--library", type=Path)
targets.add_argument("--workspace", type=Path)
command.add_argument("--port", type=valid_port, default=DEFAULT_PORT)
command.add_argument("--bridge", type=Path, default=repository / "tools/local-jobs")
if name == "serve":
command.add_argument("--handshake-fd", type=int, help=argparse.SUPPRESS)
command.set_defaults(function=function)
for name, function in (("status", status), ("stop", stop), ("code", code)):
command = subparsers.add_parser(name)
command.set_defaults(function=function)
help_command = subparsers.add_parser("help")
help_command.set_defaults(function=lambda _args: (parser.print_help() or 0))
return parser
def main(argv=None):
parser = build_parser()
args = parser.parse_args(argv)
try:
return args.function(args)
except (RuntimeError, ValueError, OSError, json.JSONDecodeError) as error:
print(f"web-app: {error}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,296 @@
#!/usr/bin/env python3
"""Bibliothèque locale privée d'espaces Labfy explicitement enregistrés."""
from __future__ import annotations
import fcntl
import hashlib
import json
import os
import subprocess
import uuid
from pathlib import Path
MAX_ENTRIES = 256
REGISTRY_CONTRACT = "labfy.web_library.registry.v1"
CREATION_INTENT_CONTRACT = "labfy.web_library.creation_intent.v1"
class LibraryError(ValueError):
"""Refus d'une opération de bibliothèque sans effet implicite sur le disque."""
class WebLibrary:
def __init__(self, root: Path, bridge: Path):
self.root = Path(root).absolute()
self.bridge = Path(bridge).resolve()
self._prepare_private_root()
self.workspaces = self.root / "workspaces"
self.workspaces.mkdir(mode=0o700, exist_ok=True)
self._assert_plain_directory(self.workspaces)
self.registry_path = self.root / "registry.json"
self.creation_intent_path = self.root / "creation-intent.json"
self.lock_path = self.root / "registry.lock"
def _prepare_private_root(self):
for component in reversed((self.root, *self.root.parents)):
if component.exists() and component.is_symlink():
raise LibraryError(
f"Lien symbolique interdit dans la bibliothèque : {component}")
if self.root.exists():
self._assert_plain_directory(self.root)
else:
self.root.mkdir(mode=0o700, parents=True)
os.chmod(self.root, 0o700)
@staticmethod
def _assert_plain_directory(path: Path):
# INVARIANT: aucun composant géré ne peut rediriger la bibliothèque.
if path.is_symlink() or not path.is_dir():
raise LibraryError(f"Répertoire de bibliothèque invalide : {path}")
def _locked(self):
descriptor = os.open(self.lock_path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
os.fchmod(descriptor, 0o600)
fcntl.flock(descriptor, fcntl.LOCK_EX)
return descriptor
def _empty_registry(self):
return {"contract": REGISTRY_CONTRACT, "generation": 0, "entries": []}
def _read_registry(self):
if not self.registry_path.exists():
return self._empty_registry()
if self.registry_path.is_symlink() or not self.registry_path.is_file():
raise LibraryError("Registre de bibliothèque invalide")
try:
value = json.loads(self.registry_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise LibraryError("Registre de bibliothèque illisible") from error
if (not isinstance(value, dict) or value.get("contract") != REGISTRY_CONTRACT or
not isinstance(value.get("generation"), int) or
not isinstance(value.get("entries"), list) or
len(value["entries"]) > MAX_ENTRIES):
raise LibraryError("Registre de bibliothèque mal formé")
seen = set()
for entry in value["entries"]:
if not self._valid_entry(entry) or entry["workspace_id"] in seen:
raise LibraryError("Entrée de bibliothèque invalide")
seen.add(entry["workspace_id"])
self._workspace_path(entry["workspace_id"], require_exists=True)
return value
@staticmethod
def _valid_entry(entry):
if not isinstance(entry, dict) or set(entry) != {
"workspace_id", "title", "idempotency_key", "request_hash"}:
return False
try:
uuid.UUID(entry["workspace_id"])
except (ValueError, TypeError, AttributeError):
return False
return (isinstance(entry["title"], str) and 0 < len(entry["title"]) <= 200 and
isinstance(entry["idempotency_key"], str) and
0 < len(entry["idempotency_key"]) <= 128 and
isinstance(entry["request_hash"], str) and
len(entry["request_hash"]) == 64)
def _write_registry(self, value):
self._write_json(self.registry_path, value, "registry")
def _write_json(self, destination, value, temporary_prefix):
temporary = self.root / f".{temporary_prefix}-{uuid.uuid4().hex}.tmp"
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
0o600)
try:
data = (json.dumps(value, ensure_ascii=False, sort_keys=True,
separators=(",", ":")) + "\n").encode()
os.write(descriptor, data)
os.fsync(descriptor)
finally:
os.close(descriptor)
temporary.replace(destination)
self._sync_root()
def _sync_root(self):
directory = os.open(self.root, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(directory)
finally:
os.close(directory)
def _read_creation_intent(self):
if not self.creation_intent_path.exists():
return None
if (self.creation_intent_path.is_symlink() or
not self.creation_intent_path.is_file()):
raise LibraryError("Intention de création invalide")
try:
value = json.loads(self.creation_intent_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise LibraryError("Intention de création illisible") from error
if (not isinstance(value, dict) or
value.get("contract") != CREATION_INTENT_CONTRACT or
not self._valid_entry({key: item for key, item in value.items()
if key != "contract"}) or
set(value) != {"contract", "workspace_id", "title",
"idempotency_key", "request_hash"}):
raise LibraryError("Intention de création mal formée")
return {key: item for key, item in value.items() if key != "contract"}
def _write_creation_intent(self, entry):
self._write_json(self.creation_intent_path,
{"contract": CREATION_INTENT_CONTRACT, **entry},
"creation-intent")
def _clear_creation_intent(self):
try:
self.creation_intent_path.unlink()
except FileNotFoundError:
return
self._sync_root()
def _workspace_path(self, workspace_id, *, require_exists):
try:
normalized = str(uuid.UUID(workspace_id))
except (ValueError, TypeError, AttributeError) as error:
raise LibraryError("Identifiant d'enquête invalide") from error
if normalized != workspace_id:
raise LibraryError("Identifiant d'enquête non canonique")
candidate = self.workspaces / normalized
if require_exists:
self._assert_plain_directory(candidate)
# CONTRACT: un espace enregistré est un enfant direct, jamais un chemin exploré.
if candidate.resolve().parent != self.workspaces.resolve():
raise LibraryError("Espace hors bibliothèque")
return candidate
@staticmethod
def _public_entry(entry):
return {"workspace_id": entry["workspace_id"], "title": entry["title"],
"state": "READY"}
def snapshot(self, active_workspace_id=None):
descriptor = self._locked()
try:
registry = self._read_registry()
finally:
os.close(descriptor)
entries = sorted((self._public_entry(item) for item in registry["entries"]),
key=lambda item: (item["title"].casefold(), item["workspace_id"]))
return {"contract": "labfy.web_library.v1", "entries": entries,
"active_workspace_id": active_workspace_id,
"generation": registry["generation"]}
def create(self, title, idempotency_key):
if (not isinstance(title, str) or not title.strip() or len(title) > 200 or
any(ord(character) < 32 for character in title)):
raise TypeError("Titre d'enquête invalide")
if (not isinstance(idempotency_key, str) or not idempotency_key or
len(idempotency_key) > 128):
raise TypeError("Clé d'idempotence invalide")
title = title.strip()
request_hash = hashlib.sha256(title.encode("utf-8")).hexdigest()
descriptor = self._locked()
try:
registry = self._read_registry()
intent = self._read_creation_intent()
if intent is not None:
published = next((item for item in registry["entries"]
if item["workspace_id"] == intent["workspace_id"]), None)
if published is not None:
if published != intent:
raise LibraryError("Intention de création incohérente avec le registre")
self._clear_creation_intent()
intent = None
replay = next((item for item in registry["entries"]
if item["idempotency_key"] == idempotency_key), None)
if replay is not None:
if replay["request_hash"] != request_hash:
raise LibraryError("Clé d'idempotence déjà liée à un autre titre")
return self._response(replay, registry["generation"], True)
if len(registry["entries"]) >= MAX_ENTRIES:
raise LibraryError("Bibliothèque limitée à 256 enquêtes")
if intent is None:
entry = {"workspace_id": str(uuid.uuid4()), "title": title,
"idempotency_key": idempotency_key,
"request_hash": request_hash}
self._write_creation_intent(entry)
workspace = self._workspace_path(entry["workspace_id"],
require_exists=False)
workspace.mkdir(mode=0o700)
workspace_ready = False
else:
if (intent["idempotency_key"] != idempotency_key or
intent["request_hash"] != request_hash):
raise LibraryError(
"Une création interrompue doit être reprise avec sa clé d'idempotence")
entry = intent
workspace = self._workspace_path(entry["workspace_id"],
require_exists=False)
if not workspace.exists():
workspace.mkdir(mode=0o700)
workspace_ready = False
else:
self._workspace_path(entry["workspace_id"], require_exists=True)
try:
self._validate_created_workspace(workspace)
workspace_ready = True
except LibraryError:
workspace_ready = False
if not workspace_ready:
# CONTRACT: le bridge C existant reste l'unique créateur de l'enquête.
result = subprocess.run(
[str(self.bridge), "create-workspace", "--workspace", str(workspace),
"--title", title], cwd=self.bridge.parents[1], text=True,
capture_output=True, timeout=30, check=False)
if result.returncode != 0:
message = (result.stderr.strip().splitlines()[-1]
if result.stderr.strip() else "Création C refusée")
raise LibraryError(message.removeprefix("local-jobs: "))
self._validate_created_workspace(workspace)
registry["entries"].append(entry)
registry["generation"] += 1
self._write_registry(registry)
self._clear_creation_intent()
return self._response(entry, registry["generation"], False)
finally:
os.close(descriptor)
@staticmethod
def _validate_created_workspace(workspace):
database = workspace / "Enquete.sqlite"
manifest = workspace / ".labfy" / "runtime" / "workspace.json"
if (database.is_symlink() or not database.is_file() or manifest.is_symlink() or
not manifest.is_file()):
raise LibraryError("Le bridge C n'a pas publié un espace valide")
try:
value = json.loads(manifest.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise LibraryError("Manifeste d'enquête invalide") from error
if value.get("contract") != "labfy.local_workspace.v1":
raise LibraryError("Contrat de manifeste d'enquête inattendu")
@staticmethod
def _response(entry, generation, replayed):
return {"contract": "labfy.web_library.workspace.v1",
**WebLibrary._public_entry(entry), "generation": generation,
"replayed": replayed}
def open(self, workspace_id, expected_generation):
if not isinstance(expected_generation, int) or isinstance(expected_generation, bool):
raise TypeError("Génération attendue invalide")
descriptor = self._locked()
try:
registry = self._read_registry()
if expected_generation != registry["generation"]:
raise LibraryError("Projection de bibliothèque périmée")
entry = next((item for item in registry["entries"]
if item["workspace_id"] == workspace_id), None)
if entry is None:
raise LibraryError("Enquête absente de la bibliothèque")
workspace = self._workspace_path(workspace_id, require_exists=True)
self._validate_created_workspace(workspace)
return workspace, entry, registry["generation"]
finally:
os.close(descriptor)

View file

@ -27,6 +27,7 @@ from report_bundle import publish as publish_report, verify as verify_report, Re
ROOT = Path(__file__).resolve().parent
PUBLIC = ROOT / "public"
MAX_BODY = 4096
SESSION_TTL_SECONDS = 3600
MAX_EXPORT = 1024 * 1024
MAX_PLAN_ITEMS = 8
MAX_UPLOAD_BYTES = 4 * 1024 * 1024
@ -47,16 +48,34 @@ class WorkspaceServer(ThreadingHTTPServer):
block_on_close = False
def __init__(self, address, handler, *, workspace: Path, bridge: Path,
bootstrap: str):
bootstrap: str, library=None, instance_id=None, config_id=None,
session_ttl_seconds=SESSION_TTL_SECONDS):
if (not isinstance(session_ttl_seconds, int) or
isinstance(session_ttl_seconds, bool) or session_ttl_seconds <= 0):
raise ValueError("Durée de session invalide")
super().__init__(address, handler)
self.workspace = workspace.resolve()
self.bridge = bridge.resolve()
self.authority = f"127.0.0.1:{self.server_port}"
self.origin = f"http://{self.authority}"
self.bootstrap = bootstrap
self.library = library
self.active_workspace_id = None
self.library_generation = (library.snapshot(None)["generation"]
if library is not None else 0)
self.instance_id = instance_id or secrets.token_urlsafe(18)
self.config_id = config_id or hashlib.sha256(
str(self.workspace).encode()).hexdigest()
self.cookie_name = f"labfy_session_{self.instance_id[:12]}"
self.session = secrets.token_urlsafe(32)
self.csrf = secrets.token_urlsafe(32)
self.session_deadline = time.monotonic() + 3600
self.session_lock = threading.RLock()
# CONTRACT: la production conserve 3600 s. L'injection constructeur
# permet seulement aux tests d'observer une vraie expiration sans route
# d'administration ni manipulation de l'horloge du processus.
self.session_ttl_seconds = session_ttl_seconds
# La durée commence à la connexion, jamais au démarrage du serveur.
self.session_deadline = 0.0
self.worker = None
self.worker_supervisor = None
self.worker_supervisor_stop = threading.Event()
@ -71,6 +90,46 @@ class WorkspaceServer(ThreadingHTTPServer):
self.reserved_upload_bytes = 0
self.upload_mutexes = {}
def library_snapshot(self):
if self.library is None:
raise ValueError("Mode bibliothèque inactif")
value = self.library.snapshot(self.active_workspace_id)
self.library_generation = value["generation"]
return value
def create_library_workspace(self, title, idempotency_key):
if self.library is None:
raise ValueError("Mode bibliothèque inactif")
value = self.library.create(title, idempotency_key)
self.library_generation = value["generation"]
return value
def open_library_workspace(self, workspace_id, expected_generation):
if self.library is None:
raise ValueError("Mode bibliothèque inactif")
with self.lock:
if (self.active_workspace_id is not None and
self.active_workspace_id != workspace_id):
raise ValueError("Une autre enquête est déjà active")
workspace, entry, generation = self.library.open(
workspace_id, expected_generation)
if self.active_workspace_id is None:
previous = self.workspace
self.workspace = workspace
try:
# CONTRACT: l'ouverture valide aussi le pipeline C existant ;
# le serveur Web ne reconstruit aucune projection lui-même.
self.bridge_call(["export"])
self.context()
except Exception:
self.workspace = previous
raise
self.active_workspace_id = workspace_id
self.library_generation = generation
return {"contract": "labfy.web_library.open.v1",
"workspace_id": workspace_id, "title": entry["title"],
"state": "READY", "generation": generation}
def upload_mutex(self, upload_id):
"""Return the process-local owner lock for one persisted upload."""
with self.upload_lock:
@ -229,18 +288,20 @@ class Handler(BaseHTTPRequestHandler):
return self.headers.get("Host") == self.server.authority
def _authenticated(self):
if time.monotonic() > self.server.session_deadline:
return False
cookie = SimpleCookie(self.headers.get("Cookie", ""))
value = cookie.get("labfy_session")
return value is not None and hmac.compare_digest(value.value,
self.server.session)
with self.server.session_lock:
if time.monotonic() >= self.server.session_deadline:
return False
cookie = SimpleCookie(self.headers.get("Cookie", ""))
value = cookie.get(self.server.cookie_name)
return value is not None and hmac.compare_digest(
value.value, self.server.session)
def _mutation_allowed(self):
return (self._authenticated() and
self.headers.get("Origin") == self.server.origin and
hmac.compare_digest(self.headers.get("X-Labfy-CSRF", ""),
self.server.csrf))
with self.server.session_lock:
return (self._authenticated() and
self.headers.get("Origin") == self.server.origin and
hmac.compare_digest(self.headers.get("X-Labfy-CSRF", ""),
self.server.csrf))
def _content_length(self, maximum, *, expected=None):
"""Validate HTTP/1.1 framing before a route consumes its body."""
@ -317,6 +378,16 @@ class Handler(BaseHTTPRequestHandler):
raise TypeError("Champ JSON vide ou trop long")
return value
def _library_open_body(self):
value = self._json_body()
if (not isinstance(value, dict) or
set(value) != {"workspace_id", "expected_generation"} or
not isinstance(value["workspace_id"], str) or
not isinstance(value["expected_generation"], int) or
isinstance(value["expected_generation"], bool)):
raise TypeError("Demande d'ouverture de bibliothèque invalide")
return value
def _plan_body(self):
value = self._json_body()
if not isinstance(value, dict) or set(value) != {
@ -412,7 +483,8 @@ class Handler(BaseHTTPRequestHandler):
path = parsed.path
if path == "/healthz":
self._json(HTTPStatus.OK, {"contract": "labfy.workspace.health.v1",
"status": "ready"})
"status": "ready", "instance_id": self.server.instance_id,
"config_id": self.server.config_id})
return
if not self._authenticated():
if path in {"/", "/login.js", "/styles.css"}:
@ -430,7 +502,15 @@ class Handler(BaseHTTPRequestHandler):
"workspace_state": "READY" if context else "EMPTY",
"investigation_id": context.get("investigation_id") if context else None,
"title": context.get("title", "SPECIMEN") if context else None,
"mode": context.get("mode", "specimen") if context else "local_experimental"})
"mode": context.get("mode", "specimen") if context else "local_experimental",
"library_mode": self.server.library is not None,
"active_workspace_id": self.server.active_workspace_id,
"generation": self.server.library_generation})
elif path == "/api/v1/library":
try:
self._json(HTTPStatus.OK, self.server.library_snapshot())
except (OSError, ValueError, json.JSONDecodeError) as error:
self._error(HTTPStatus.CONFLICT, "library_unavailable", str(error))
elif path.startswith("/api/v1/evidence/"):
parts = path.split("/")
if len(parts) != 6 or parts[5] not in {"preview", "observations"}:
@ -518,7 +598,21 @@ class Handler(BaseHTTPRequestHandler):
return
self.server.last_command = now
try:
if path == "/api/v1/library/workspaces":
value = self._body({"title", "idempotency_key"})
response = self.server.create_library_workspace(
value["title"], value["idempotency_key"])
self._json(HTTPStatus.OK if response["replayed"] else HTTPStatus.CREATED,
response)
return
if path == "/api/v1/library/open":
value = self._library_open_body()
self._json(HTTPStatus.OK, self.server.open_library_workspace(
value["workspace_id"], value["expected_generation"]))
return
if path == "/api/v1/workspace":
if self.server.library is not None:
raise ValueError("Utiliser la création explicite de la bibliothèque")
value = self._body({"title"})
if self.server.context() is not None:
raise ValueError("L’espace est déjà créé")
@ -911,8 +1005,16 @@ class Handler(BaseHTTPRequestHandler):
if not hmac.compare_digest(value["bootstrap_code"], self.server.bootstrap):
self._error(HTTPStatus.FORBIDDEN, "bootstrap_rejected", "Code éphémère invalide")
return
cookie = (f"labfy_session={self.server.session}; HttpOnly; SameSite=Strict; "
"Path=/; Max-Age=3600")
with self.server.session_lock:
# Une reconnexion renouvelle les secrets ; les anciens cookies
# expirés ne doivent pas redevenir valables.
self.server.session = secrets.token_urlsafe(32)
self.server.csrf = secrets.token_urlsafe(32)
self.server.session_deadline = (time.monotonic() +
self.server.session_ttl_seconds)
cookie = (f"{self.server.cookie_name}={self.server.session}; "
"HttpOnly; SameSite=Strict; "
f"Path=/; Max-Age={self.server.session_ttl_seconds}")
self._json(HTTPStatus.OK, {"contract": "labfy.workspace.session.v1",
"authenticated": True}, cookie=cookie)
@ -961,7 +1063,12 @@ def main():
parser = argparse.ArgumentParser()
parser.add_argument("--workspace", type=Path, required=True)
parser.add_argument("--bridge", type=Path, required=True)
parser.add_argument("--port", type=int, default=8765)
def port(value):
parsed = int(value)
if not 0 <= parsed <= 65535:
raise argparse.ArgumentTypeError("port hors limites")
return parsed
parser.add_argument("--port", type=port, default=8081)
args = parser.parse_args()
bootstrap = secrets.token_urlsafe(12)
server = WorkspaceServer(("127.0.0.1", args.port), Handler,

7
tools/labfy-web Executable file
View file

@ -0,0 +1,7 @@
#!/bin/sh
# CONTRACT: ce lanceur ne compile rien et délègue les arguments tels quels au
# gestionnaire d'instance, qui impose loopback, le port et le type de cible.
set -eu
repository=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
exec python3 "$repository/prototypes/web-graph/web_app.py" "$@"