feat(web): deploy local investigation workbench with Qwen

This commit is contained in:
fy59 2026-09-28 17:27:01 +02:00
parent 537ad97f79
commit f72a192d4d
20 changed files with 532 additions and 41 deletions

View file

@ -27,6 +27,13 @@ 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 locale ne change ni le statut de production, ni l’interdiction d’utiliser une
enquête réelle pendant les validations de développement. enquête réelle pendant les validations de développement.
Le déploiement personnel local de `http://invest.labfy` repose sur une unité
systemd utilisateur et un proxy nginx limités à `127.0.0.1`. Sur cette machine,
le service démarre sans inspecter la bibliothèque configurée ; l'utilisateur
charge sa liste, puis ouvre une enquête par deux actions explicites. Labfy
démarre le Qwen local configuré dans XDG pour le prompt Agent existant. Les étapes
d’installation et de retrait sont dans le [guide du poste Web](docs/ui/WEB_WORKBENCH.md).
Le parcours local expérimental d’import et de revue est détaillé dans 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) : [le contrat d’espace local](docs/architecture/LOCAL_WORKSPACE_IMPORT.md) :
l’aperçu EML/PNG/JPEG provient du cœur C après contrôle d’intégrité ; les l’aperçu EML/PNG/JPEG provient du cœur C après contrôle d’intégrité ; les

View file

@ -10,6 +10,12 @@
> L'exécution intrusive reste `LAB-ONLY TARGET`; Tool Auto-Provisioning, > L'exécution intrusive reste `LAB-ONLY TARGET`; Tool Auto-Provisioning,
> Resource Governor et Persistent Agent Memory restent `TARGET`. > Resource Governor et Persistent Agent Memory restent `TARGET`.
> Déploiement personnel `invest.labfy` : `CURRENT local` sur cette machine
> après validation `SPECIMEN` du proxy nginx, de la résolution hostname,
> du service systemd utilisateur, du démarrage différé de la bibliothèque et
> du prompt Agent avec Qwen réel (`investigation.search`, final `COMPLETED`).
> Aucune enquête réelle n'a été ouverte pendant cette validation locale.
## 1. Ordre et portes ## 1. Ordre et portes
Labfy évolue sans réécriture générale. Les contrats du cœur précèdent les Labfy évolue sans réécriture générale. Les contrats du cœur précèdent les

View file

@ -42,6 +42,13 @@ puis ciblé après délai, ne concerne que son PID/groupe de processus possédé
Le chemin de modèle reste hors du dépôt. Sans configuration, binaire ou Le chemin de modèle reste hors du dépôt. Sans configuration, binaire ou
healthcheck valides, l'agent est indisponible sans bascule cloud ou fake. healthcheck valides, l'agent est indisponible sans bascule cloud ou fake.
Le service local utilise `--agent-mode local-model --agent-autostart
--agent-timeout 90`. Le superviseur démarre son propre serveur modèle sur un
port loopback éphémère ; le navigateur appelle seulement Labfy. Le champ
« Objectif utilisateur » existant lance le turn Qwen et ses appels d'outils
passent par `AgentRuntime` et les capabilities. L'interface distingue Qwen prêt,
occupé et indisponible. Le choix manuel d'outil reste dans « Mode expert/debug ».
## Limites actuelles ## Limites actuelles
La fondation Sandbox/Tool Registry et l'egress privacy sont décrites dans leurs La fondation Sandbox/Tool Registry et l'egress privacy sont décrites dans leurs

View file

@ -41,6 +41,15 @@ le code éphémère. Ne pas consigner ce code dans des scripts, URLs, journaux o
documents. Le mode `--workspace` reste le parcours ciblé existant ; il ne crée documents. Le mode `--workspace` reste le parcours ciblé existant ; il ne crée
pas une bibliothèque implicite. pas une bibliothèque implicite.
Le service personnel `invest.labfy` emploie `--lazy-library`. Dans ce mode,
le démarrage conserve seulement le chemin configuré : il ne crée ni ne lit la
bibliothèque et n'ouvre aucune enquête. La première page établit la session
automatique sans appeler l'API de liste. L'action visible « Charger les
enquêtes » déclenche seule l'initialisation et la lecture du registre ;
l'ouverture d'une enquête exige ensuite un clic distinct. Le workspace
inactif du serveur réside dans son espace XDG privé, hors de la bibliothèque.
Le parcours `make web` sans cette option conserve son chargement initial.
## Bibliothèque, état XDG et limites ## Bibliothèque, état XDG et limites
La bibliothèque est privée (`0700`), refuse les liens symboliques et conserve La bibliothèque est privée (`0700`), refuse les liens symboliques et conserve

View file

@ -113,3 +113,45 @@ Le panneau Agent indique explicitement le parcours déterministe, un modèle loc
configuré ou son indisponibilité. Les tours de modèle sont asynchrones, bornés configuré ou son indisponibilité. Les tours de modèle sont asynchrones, bornés
et leurs cartes ne rendent jamais de HTML, de prompt complet ou de raisonnement et leurs cartes ne rendent jamais de HTML, de prompt complet ou de raisonnement
privé. Le contrat de ce runtime est [LOCAL_MODEL_AGENT_RUNTIME.md](../architecture/LOCAL_MODEL_AGENT_RUNTIME.md). privé. Le contrat de ce runtime est [LOCAL_MODEL_AGENT_RUNTIME.md](../architecture/LOCAL_MODEL_AGENT_RUNTIME.md).
## Déploiement personnel `invest.labfy`
Les templates [systemd](../../packaging/systemd/labfy-investigation-web.service)
et [nginx](../../packaging/nginx/invest.labfy.conf) prévoient un poste publié uniquement
sur la boucle locale. Le wrapper [labfy-investigation-web](../../scripts/labfy-investigation-web)
exécute `web_app.py serve --library --lazy-library` en avant-plan sur le port dédié `8091`,
avec session automatique et Qwen local démarré par Labfy. Le chemin de la
bibliothèque et celui du modèle restent dans les fichiers XDG privés, jamais
dans Git. L'unité est installée dans `~/.config/systemd/user/` et le wrapper
est lié depuis `~/.local/bin/`. `web.env` contient `LABFY_LIBRARY` (chemin
absolu explicitement choisi) et `LABFY_WEB_PORT=8091`, avec mode `0600`.
`agent.json` suit le contrat de configuration de `LocalModelSupervisor`.
À l'arrivée, la page n'inspecte pas la bibliothèque et n'ouvre aucune enquête.
L'utilisateur choisit « Charger les enquêtes », puis ouvre explicitement
l'enquête souhaitée. Le lancement du service seul ne lit pas leurs données.
Le bloc nginx doit être placé dans `http {}` de `/etc/nginx/nginx.conf`, puis
`127.0.0.1 invest.labfy` doit être ajouté une fois à `/etc/hosts`.
Exécuter `nginx -t` avant de recharger nginx. Le backend vérifie exactement
`Host: invest.labfy` et `Origin: http://invest.labfy` pour ce mode ; il garde
son autorité loopback propre, le CSRF et le cookie HttpOnly/SameSite=Strict.
Le proxy préserve Host et Origin, désactive le buffering pour l'activité et
borne la lecture à 180 s. Il ne rend jamais le port modèle au navigateur.
Une fois la validation `SPECIMEN` terminée, l'opérateur peut appliquer les deux
changements root par `sudo python3 scripts/apply-local-web-root.py` depuis la
racine du dépôt. Le script refuse les conflits, crée des sauvegardes distinctes
dans `/etc`, valide la configuration réelle par `nginx -t`, puis recharge nginx.
Un second passage ne duplique rien.
Pour les validations, configurer temporairement `LABFY_LIBRARY` vers une
bibliothèque `SPECIMEN`, puis lancer `systemctl --user start
labfy-investigation-web.service`. Après validation, arrêter l'unité et
remettre le chemin personnel sans l'ouvrir. Le service peut alors être activé
et démarré : son mode différé attend une action explicite dans l'interface
avant de consulter la bibliothèque. Les validations utilisent toujours une
bibliothèque `SPECIMEN` distincte.
Le retrait est : `systemctl --user disable --now
labfy-investigation-web.service`, supprimer uniquement l'unité et le lien
utilisateur installés, retirer le bloc `invest.labfy` et sa ligne hosts, puis
valider `nginx -t` avant rechargement. Ne toucher ni au bloc Trainlog ni à son
service.

View file

@ -0,0 +1,16 @@
# CONTRACT: this server is included inside nginx's http block. Backend and
# public hostname stay on loopback; Host and Origin reach Labfy unchanged.
server {
listen 127.0.0.1:80;
server_name invest.labfy;
location / {
proxy_pass http://127.0.0.1:8091;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 180s;
}
}

View file

@ -0,0 +1,22 @@
[Unit]
Description=Labfy Investigation Web local service
After=default.target
[Service]
Type=simple
EnvironmentFile=%h/.config/labfy-investigation/web.env
ExecStart=%h/.local/bin/labfy-investigation-web
Restart=on-failure
RestartSec=10
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectControlGroups=true
ProtectKernelModules=true
ProtectKernelTunables=true
RestrictSUIDSGID=true
LockPersonality=true
SystemCallArchitectures=native
[Install]
WantedBy=default.target

View file

@ -9,6 +9,17 @@ import { GraphRenderer, presentationKind } from "./graph-renderer.js";
const byId = (id) => document.getElementById(id); const byId = (id) => document.getElementById(id);
const svg = byId("graph"); const svg = byId("graph");
function newUuid() {
// CONTRACT: le poste local peut être servi en HTTP sous un nom dédié. Firefox
// y expose getRandomValues, mais réserve randomUUID aux contextes sécurisés.
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, "0")).join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
function prepareSnapshot(value) { function prepareSnapshot(value) {
if (value?.contract === "labfy.web_graph.error.v1") { if (value?.contract === "labfy.web_graph.error.v1") {
return { return {
@ -416,7 +427,7 @@ async function startAgentMission() {
max_tool_calls: 16, max_tool_calls: 16,
}, },
human_confirmed: true, human_confirmed: true,
idempotency_key: crypto.randomUUID(), idempotency_key: newUuid(),
}); });
renderAgentMission(value); renderAgentMission(value);
appendAgentActivity({ appendAgentActivity({
@ -433,7 +444,7 @@ async function cancelAgentMission() {
workspace_id: workspaceContext.workspaceId, workspace_id: workspaceContext.workspaceId,
mission_id: missionId, mission_id: missionId,
human_confirmed: true, human_confirmed: true,
idempotency_key: crypto.randomUUID(), idempotency_key: newUuid(),
}); });
renderAgentMission(value); renderAgentMission(value);
appendAgentActivity({ appendAgentActivity({
@ -461,11 +472,11 @@ async function decideAgentProposal(record, action) {
`/api/v1/agent-proposals/${encodeURIComponent(record.proposal_id)}/${action}`, `/api/v1/agent-proposals/${encodeURIComponent(record.proposal_id)}/${action}`,
{ {
workspace_id: workspaceContext.workspaceId, workspace_id: workspaceContext.workspaceId,
decision_id: crypto.randomUUID(), decision_id: newUuid(),
reason: "Décision explicite depuis l’interface locale", reason: "Décision explicite depuis l’interface locale",
decided_by: "opérateur local", decided_by: "opérateur local",
decided_at: new Date().toISOString(), decided_at: new Date().toISOString(),
idempotency_key: crypto.randomUUID(), idempotency_key: newUuid(),
}, },
); );
await refreshAgentProposals(); await refreshAgentProposals();
@ -542,9 +553,9 @@ async function loadAgentRuntime() {
if (value.mode === "DETERMINISTIC_DEMO") return false; if (value.mode === "DETERMINISTIC_DEMO") return false;
agentMode = "LOCAL"; agentMode = "LOCAL";
if (value.mode === "LOCAL_MODEL" && value.available === true) { if (value.mode === "LOCAL_MODEL" && value.available === true) {
byId("agent-status").textContent = `Agent local · ${value.model ?? "modèle configuré"}`; byId("agent-status").textContent = `Qwen prêt · ${value.model ?? "modèle configuré"}`;
byId("agent-send").disabled = false; byId("agent-send").disabled = false;
byId("agent-system-qwen").textContent = value.model ?? "disponible"; byId("agent-system-qwen").textContent = "prêt";
} else { } else {
byId("agent-status").textContent = byId("agent-status").textContent =
`Modèle local indisponible · ${shortAgentReason(value.reason)}`; `Modèle local indisponible · ${shortAgentReason(value.reason)}`;
@ -754,6 +765,10 @@ async function refreshAgentEvents() {
clearInterval(agentEventTimer); clearInterval(agentEventTimer);
agentEventTimer = null; agentEventTimer = null;
} }
if (turn?.terminal && agentMode === "LOCAL") {
byId("agent-system-qwen").textContent =
agentTurnState === "MODEL_UNAVAILABLE" ? "indisponible" : "prêt";
}
byId("activity-filter").dispatchEvent(new Event("change")); byId("activity-filter").dispatchEvent(new Event("change"));
} finally { } finally {
agentEventsRefreshing = false; agentEventsRefreshing = false;
@ -784,8 +799,9 @@ async function callAgentTool() {
const path = agentMode === "LOCAL" const path = agentMode === "LOCAL"
? "/api/v1/agent-runtime/turns" : "/api/v1/agent-tools/turns"; ? "/api/v1/agent-runtime/turns" : "/api/v1/agent-tools/turns";
const body = agentMode === "LOCAL" const body = agentMode === "LOCAL"
? { objective, idempotency_key: crypto.randomUUID() } : { objective }; ? { objective, idempotency_key: newUuid() } : { objective };
const admitted = await postCommand(path, body); const admitted = await postCommand(path, body);
if (agentMode === "LOCAL") byId("agent-system-qwen").textContent = "occupé";
activeAgentTurnId = admitted.turn_id; activeAgentTurnId = admitted.turn_id;
agentTurnState = admitted.state ?? "QUEUED"; agentTurnState = admitted.state ?? "QUEUED";
agentEventCursor = 0; agentEventCursor = 0;
@ -829,7 +845,7 @@ async function evidenceJson(path) {
function reviewEnvelope(observation, reason) { function reviewEnvelope(observation, reason) {
return { return {
operation_id: crypto.randomUUID(), operation_id: newUuid(),
expected_revision: String(observation.revision), expected_revision: String(observation.revision),
author: "opérateur local", author: "opérateur local",
reason, reason,
@ -986,7 +1002,7 @@ function configureImport() {
// CONTRACT: la sélection reste multiple, mais le client respecte la // CONTRACT: la sélection reste multiple, mais le client respecte la
// concurrence serveur au lieu de transformer le troisième fichier en // concurrence serveur au lieu de transformer le troisième fichier en
// erreur artificielle. La limite reste contrôlée côté serveur. // erreur artificielle. La limite reste contrôlée côté serveur.
const selectionId=crypto.randomUUID(); const selectionId=newUuid();
for(const file of files) await receiveFile(file,selectionId); for(const file of files) await receiveFile(file,selectionId);
event.target.value=""; event.target.value="";
}); });
@ -994,7 +1010,7 @@ function configureImport() {
byId("import-confirm").disabled=true; byId("import-confirm").disabled=true;
for(const [id,prepared] of [...preparedUploads]){ for(const [id,prepared] of [...preparedUploads]){
try{await postCommand(`/api/v1/uploads/${id}/confirm`,{ try{await postCommand(`/api/v1/uploads/${id}/confirm`,{
idempotency_key:crypto.randomUUID(),source:byId("import-source").value || "Non déclarée", idempotency_key:newUuid(),source:byId("import-source").value || "Non déclarée",
description:byId("import-description").value || "Sans commentaire"}); description:byId("import-description").value || "Sans commentaire"});
preparedUploads.delete(id);byId("import-status").textContent=`${prepared.name} importé avec l’UUID ${prepared.evidence_id}.`; preparedUploads.delete(id);byId("import-status").textContent=`${prepared.name} importé avec l’UUID ${prepared.evidence_id}.`;
}catch(error){byId("import-status").textContent=error.message;} }catch(error){byId("import-status").textContent=error.message;}
@ -1358,7 +1374,7 @@ async function prepareResearch() {
selection_ids: [node.id], selection_ids: [node.id],
question: byId("research-question").value.trim(), question: byId("research-question").value.trim(),
exclusions, exclusions,
idempotency_key: crypto.randomUUID(), idempotency_key: newUuid(),
}); });
byId("research-note").textContent = byId("research-note").textContent =
"Plan préparé par le cœur C. Choisissez chaque action avant le lancement final."; "Plan préparé par le cœur C. Choisissez chaque action avant le lancement final.";
@ -1382,7 +1398,7 @@ async function launchResearch() {
selected_action_ids: actionIds, selected_action_ids: actionIds,
decisions, decisions,
exclusions, exclusions,
idempotency_key: crypto.randomUUID(), idempotency_key: newUuid(),
}); });
const grantId = grant.grants[0]?.grant_id; const grantId = grant.grants[0]?.grant_id;
if (!grantId) throw new Error("Autorisation durable absente."); if (!grantId) throw new Error("Autorisation durable absente.");
@ -1390,7 +1406,7 @@ async function launchResearch() {
grant_id: grantId, grant_id: grantId,
input_revision: grant.input_revision, input_revision: grant.input_revision,
action_ids: actionIds, action_ids: actionIds,
idempotency_key: crypto.randomUUID(), idempotency_key: newUuid(),
}); });
// CONTRACT: seul le flux C existant persiste grant et campagne. Le turn ne // CONTRACT: seul le flux C existant persiste grant et campagne. Le turn ne
// reprend qu'après ce succès ; l'agent ne possède aucune voie d'autorisation. // reprend qu'après ce succès ; l'agent ne possède aucune voie d'autorisation.
@ -1839,7 +1855,7 @@ async function generateReport() {
if (!reportPreview) return; if (!reportPreview) return;
const admission = await postCommand("/api/v1/reports", { const admission = await postCommand("/api/v1/reports", {
preview_revision: reportPreview.revision, preview_revision: reportPreview.revision,
idempotency_key: crypto.randomUUID(), idempotency_key: newUuid(),
}); });
byId("report-status").textContent = "Génération locale en cours…"; byId("report-status").textContent = "Génération locale en cours…";
for (let i = 0; i < 100; i++) { for (let i = 0; i < 100; i++) {
@ -1897,7 +1913,7 @@ async function runCapability(capability) {
if (button) button.disabled = true; if (button) button.disabled = true;
const storageKey = `labfy-intent:${workspaceContext.workspaceId}:` + const storageKey = `labfy-intent:${workspaceContext.workspaceId}:` +
`${workspaceContext.generation}:${node.object_id}:${capability.id}`; `${workspaceContext.generation}:${node.object_id}:${capability.id}`;
const key = sessionStorage.getItem(storageKey) ?? crypto.randomUUID(); const key = sessionStorage.getItem(storageKey) ?? newUuid();
sessionStorage.setItem(storageKey, key); sessionStorage.setItem(storageKey, key);
try { try {
await postCommand("/api/v1/jobs", { await postCommand("/api/v1/jobs", {
@ -2442,24 +2458,31 @@ function renderLibrary(value) {
async function refreshLibrary() { async function refreshLibrary() {
const value = await libraryRequest("/api/v1/library", { cache: "no-store" }); const value = await libraryRequest("/api/v1/library", { cache: "no-store" });
renderLibrary(value); renderLibrary(value);
byId("library-load").hidden = true;
byId("workspace-create").hidden = false;
byId("library-list-section").hidden = false;
return value; return value;
} }
async function showLibrary({ focus = false } = {}) { async function showLibrary({ focus = false, load = true } = {}) {
invalidateWorkspaceContext(); invalidateWorkspaceContext();
for (const dialog of document.querySelectorAll("dialog[open]")) dialog.close(); for (const dialog of document.querySelectorAll("dialog[open]")) dialog.close();
preparedUploads.clear(); preparedUploads.clear();
workspaceReady = false; workspaceReady = false;
byId("workbench-shell").hidden = true; byId("workbench-shell").hidden = true;
byId("library-home").hidden = false; byId("library-home").hidden = false;
byId("workspace-create").hidden = false; byId("workspace-create").hidden = !load;
byId("library-list-section").hidden = false; byId("library-list-section").hidden = !load;
byId("library-load").hidden = load;
byId("library-title").textContent = "Bibliothèque d’enquêtes"; byId("library-title").textContent = "Bibliothèque d’enquêtes";
byId("library-connection").textContent = "Actualisation…"; byId("library-connection").textContent = load
try { ? "Actualisation…" : "Bibliothèque non chargée";
await refreshLibrary(); if (load) {
} catch (error) { try {
byId("library-connection").textContent = error.message; await refreshLibrary();
} catch (error) {
byId("library-connection").textContent = error.message;
}
} }
if (focus) byId("library-title").focus(); if (focus) byId("library-title").focus();
} }
@ -2654,6 +2677,18 @@ function configureApplication() {
} }
}); });
byId("home-button").addEventListener("click", () => void showLibrary({ focus: true })); byId("home-button").addEventListener("click", () => void showLibrary({ focus: true }));
byId("library-load").addEventListener("click", async (event) => {
const button = event.currentTarget;
button.disabled = true;
byId("library-connection").textContent = "Actualisation…";
try {
await refreshLibrary();
} catch (error) {
byId("library-connection").textContent = error.message;
} finally {
button.disabled = false;
}
});
byId("workspace-create-form").addEventListener("submit", async (event) => { byId("workspace-create-form").addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
const button = event.currentTarget.querySelector("button"); const button = event.currentTarget.querySelector("button");
@ -2662,7 +2697,7 @@ function configureApplication() {
try { try {
const title = byId("workspace-title").value.trim(); const title = byId("workspace-title").value.trim();
if (pendingCreateIntent?.title !== title) if (pendingCreateIntent?.title !== title)
pendingCreateIntent = { title, idempotencyKey: crypto.randomUUID() }; pendingCreateIntent = { title, idempotencyKey: newUuid() };
const path = libraryMode ? "/api/v1/library/workspaces" : "/api/v1/workspace"; const path = libraryMode ? "/api/v1/library/workspaces" : "/api/v1/workspace";
const body = libraryMode const body = libraryMode
? { title, idempotency_key: pendingCreateIntent.idempotencyKey } ? { title, idempotency_key: pendingCreateIntent.idempotencyKey }
@ -2731,7 +2766,7 @@ function configureApplication() {
...byId("planner-list").querySelectorAll("input:checked"), ...byId("planner-list").querySelectorAll("input:checked"),
].map((input) => input.value); ].map((input) => input.value);
if (!ids.length || !plannerValue) return; if (!ids.length || !plannerValue) return;
const key = crypto.randomUUID(); const key = newUuid();
try { try {
await postCommand("/api/v1/plans", { await postCommand("/api/v1/plans", {
recommendation_ids: ids, recommendation_ids: ids,
@ -2782,7 +2817,7 @@ async function start() {
configureApplication(); configureApplication();
// WHY: seule l'application bibliothèque ouvre toujours sur l'accueil ; les // WHY: seule l'application bibliothèque ouvre toujours sur l'accueil ; les
// serveurs historiques et de démonstration conservent leur contrat direct. // serveurs historiques et de démonstration conservent leur contrat direct.
if (libraryMode) await showLibrary(); if (libraryMode) await showLibrary({ load: session?.library_load_required !== true });
else if (session?.workspace_state === "EMPTY") showLegacyCreation(); else if (session?.workspace_state === "EMPTY") showLegacyCreation();
else await activateLegacyWorkspace(session); else await activateLegacyWorkspace(session);
} catch (error) { } catch (error) {

View file

@ -12,6 +12,7 @@
<div><span class="eyebrow">Poste local</span><h1 id="library-title" tabindex="-1">Bibliothèque d’enquêtes</h1></div> <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> <span id="library-connection" role="status">Chargement…</span>
</header> </header>
<p><button id="library-load" type="button" hidden>Charger les enquêtes</button></p>
<section id="workspace-create" class="library-create" aria-labelledby="create-title"> <section id="workspace-create" class="library-create" aria-labelledby="create-title">
<h2 id="create-title">Nouvelle enquête</h2> <h2 id="create-title">Nouvelle enquête</h2>
<p>Crée un espace local vide. Aucun contenu d’exemple n’est ajouté.</p> <p>Crée un espace local vide. Aucun contenu d’exemple n’est ajouté.</p>
@ -92,7 +93,7 @@
</nav> </nav>
<main class="workbench"> <main class="workbench">
<aside id="agent-panel" class="agent-panel" aria-label="Agent IA local"> <aside id="agent-panel" class="agent-panel" aria-label="Agent IA local">
<div class="panel-heading"><div><span class="eyebrow">Agent de démonstration déterministe</span><h2>Mission</h2></div><button class="pane-toggle" data-pane="agent-panel" aria-expanded="true">Réduire</button></div> <div class="panel-heading"><div><span class="eyebrow">Agent local</span><h2>Mission</h2></div><button class="pane-toggle" data-pane="agent-panel" aria-expanded="true">Réduire</button></div>
<label class="agent-prompt"> <label class="agent-prompt">
<span>Objectif utilisateur</span> <span>Objectif utilisateur</span>
<textarea id="agent-prompt" maxlength="512" placeholder="Objectif d’enquête SPECIMEN…" <textarea id="agent-prompt" maxlength="512" placeholder="Objectif d’enquête SPECIMEN…"

View file

@ -29,6 +29,9 @@ export const scenarios = Object.freeze([
// Le scénario import/rapport précédent et ce cycle du vrai launcher forment // 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. // la preuve E2E : données SPECIMEN, rapport, bibliothèque, redémarrages et A/B.
"test_web_app_browser.mjs", "test_web_app_browser.mjs",
// CONTRACT: le service persistant n'inspecte la bibliothèque qu'après le
// clic explicite de l'utilisateur, puis ouvre un workspace SPECIMEN choisi.
"test_lazy_library_browser.mjs",
// CONTRACT: ce parcours Firefox exerce le vrai formulaire et une expiration // 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 // 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. // rejouer une mutation dont l'admission n'est pas connue.

View file

@ -54,6 +54,40 @@ class AutomaticSessionTest(unittest.TestCase):
"Content-Type": "application/json", "Content-Length": "2"}, "{}") "Content-Type": "application/json", "Content-Length": "2"}, "{}")
self.assertEqual(status, 403) self.assertEqual(status, 403)
def test_explicit_proxy_origin_keeps_host_origin_and_csrf_strict(self):
self.server.shutdown(); self.server.server_close(); self.thread.join()
self.server = WorkspaceServer(("127.0.0.1", 0), Handler,
workspace=Path(self.temporary.name),
bridge=Path(self.temporary.name) / "missing-bridge", bootstrap="unused",
public_local_origin="http://invest.labfy")
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
self.authority = f"127.0.0.1:{self.server.server_port}"
status, headers, _ = self.request("GET", "/", {"Host": "invest.labfy"})
self.assertEqual(status, 303)
cookie = headers["Set-Cookie"].split(";", 1)[0]
status, _, body = self.request("GET", "/api/v1/session", {
"Host": "invest.labfy", "Cookie": cookie})
self.assertEqual(status, 200)
session = json.loads(body)
self.assertEqual(session["origin"], "http://invest.labfy")
common = {"Host": "invest.labfy", "Cookie": cookie,
"X-Labfy-CSRF": session["csrf"],
"Content-Type": "application/json"}
for origin in ("http://evil.test", self.server.origin):
status, _, _ = self.request("POST", "/api/v1/queue/pause",
{**common, "Origin": origin}, "{}")
self.assertEqual(status, 403)
status, _, _ = self.request("POST", "/api/v1/queue/pause",
{**common, "Origin": "http://invest.labfy",
"X-Labfy-CSRF": "wrong"}, "{}")
self.assertEqual(status, 403)
status, _, _ = self.request("GET", "/", {"Host": "evil.test"})
self.assertEqual(status, 403)
status, _, _ = self.request("GET", "/", {
"Host": "evil.test", "X-Forwarded-Host": "invest.labfy"})
self.assertEqual(status, 403)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -0,0 +1,80 @@
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { once } from "node:events";
import { mkdtemp, mkdir, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import puppeteer from "puppeteer-core";
import { openAutomaticSession } from "./browser_automatic_session.mjs";
const prototype = new URL("..", import.meta.url);
const root = await mkdtemp(join(tmpdir(), "labfy-lazy-browser-SPECIMEN-"));
const library = join(root, "library-SPECIMEN");
const runtime = join(root, "runtime");
const state = join(root, "state");
const profile = join(root, "firefox-profile");
await mkdir(runtime, { mode: 0o700 });
await mkdir(state, { mode: 0o700 });
let server;
let browser;
try {
// CONTRACT: the only library passed to this owned server is a fresh SPECIMEN
// path. The first browser navigation must leave it absent on disk.
server = spawn("python3", ["web_app.py", "serve", "--library", library,
"--lazy-library", "--port", "0"], {
cwd: prototype,
env: { ...process.env, XDG_RUNTIME_DIR: runtime, XDG_STATE_HOME: state,
PYTHONUNBUFFERED: "1" },
stdio: ["ignore", "pipe", "pipe"],
});
const origin = await new Promise((resolve, reject) => {
let output = "";
const timer = setTimeout(() => reject(new Error("démarrage lazy expiré")), 15000);
server.stdout.on("data", (chunk) => {
output += chunk.toString();
const match = output.match(/Labfy Web : (http:\/\/127\.0\.0\.1:\d+)\//);
if (match) {
clearTimeout(timer);
resolve(match[1]);
}
});
server.once("exit", (code) => reject(new Error(`serveur lazy arrêté : ${code}`)));
});
browser = await puppeteer.launch({ browser: "firefox", executablePath: "/usr/bin/firefox",
headless: true, userDataDir: profile, args: ["--no-remote"] });
const page = await browser.newPage();
await page.setViewport({ width: 1360, height: 980 });
const libraryRequests = [];
page.on("request", (request) => {
if (new URL(request.url()).pathname === "/api/v1/library")
libraryRequests.push(request.url());
});
const session = await openAutomaticSession(page, origin, "lazy SPECIMEN");
assert.equal(session.library_load_required, true);
assert.equal(session.active_workspace_id, null);
assert.equal(libraryRequests.length, 0);
await assert.rejects(stat(library), { code: "ENOENT" });
assert.equal(await page.$eval("#library-load", (button) => button.hidden), false);
assert.equal(await page.$eval("#workspace-create", (section) => section.hidden), true);
await page.click("#library-load");
await page.waitForSelector("#workspace-create:not([hidden])");
assert.equal(libraryRequests.length, 1);
assert.equal((await stat(library)).isDirectory(), true);
await page.type("#workspace-title", "Enquête lazy SPECIMEN");
await page.click("#workspace-create-form button");
await page.waitForSelector(".workspace-card [data-workspace-id]");
await page.click(".workspace-card [data-workspace-id]");
await page.waitForSelector("#workbench-shell:not([hidden])");
assert.equal(await page.$eval("#investigation-title", (node) => node.textContent),
"Enquête lazy SPECIMEN");
console.log("PASS Firefox lazy library : aucun accès avant clic, ouverture explicite SPECIMEN");
} finally {
if (browser) await browser.close();
if (server?.exitCode === null) {
server.kill("SIGTERM");
await once(server, "exit");
}
await rm(root, { recursive: true, force: true });
}

View file

@ -128,7 +128,7 @@ try {
await page.setViewport({ width: 1280, height: 900 }); await page.setViewport({ width: 1280, height: 900 });
await openAutomaticSession(page, origin); await openAutomaticSession(page, origin);
await page.waitForFunction(() => await page.waitForFunction(() =>
document.querySelector("#agent-status")?.textContent === "Agent local · qwen-SPECIMEN"); document.querySelector("#agent-status")?.textContent === "Qwen prêt · qwen-SPECIMEN");
const hostile = '<img src=x onerror="window.__localAgentInjected=true"> SPECIMEN'; const hostile = '<img src=x onerror="window.__localAgentInjected=true"> SPECIMEN';
await page.type("#agent-prompt", hostile); await page.type("#agent-prompt", hostile);

View file

@ -111,7 +111,7 @@ try {
await page.setViewport({ width: 1360, height: 980 }); await page.setViewport({ width: 1360, height: 980 });
const session = await openAutomaticSession(page, origin, "agent opérationnel"); const session = await openAutomaticSession(page, origin, "agent opérationnel");
await page.waitForFunction(() => await page.waitForFunction(() =>
document.querySelector("#agent-system-qwen")?.textContent === "qwen-SPECIMEN"); document.querySelector("#agent-system-qwen")?.textContent === "prêt");
assert.equal(await page.$eval("#agent-system-sandbox", (node) => node.textContent), assert.equal(await page.$eval("#agent-system-sandbox", (node) => node.textContent),
"indisponible"); "indisponible");
assert.equal(await page.$eval("#agent-system-privacy", (node) => node.textContent), assert.equal(await page.$eval("#agent-system-privacy", (node) => node.textContent),

View file

@ -139,6 +139,50 @@ class LibraryTest(SyntheticEnvironment):
intent = json.loads(self.library.creation_intent_path.read_text(encoding="utf-8")) intent = json.loads(self.library.creation_intent_path.read_text(encoding="utf-8"))
return intent["workspace_id"] return intent["workspace_id"]
def test_lazy_library_waits_for_explicit_listing(self):
root = self.root / "lazy-SPECIMEN"
idle = self.root / "private-idle"
idle.mkdir()
library = WebLibrary(root, self.bridge, lazy=True)
self.assertFalse(root.exists())
server = WorkspaceServer(("127.0.0.1", 0), Handler,
workspace=idle, bridge=self.bridge, library=library)
self.assertFalse(root.exists())
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
def request(path, cookie=None):
connection = http.client.HTTPConnection("127.0.0.1", server.server_port,
timeout=3)
headers = {"Host": server.authority}
if cookie:
headers["Cookie"] = cookie
connection.request("GET", path, headers=headers)
response = connection.getresponse()
result = response.status, dict(response.getheaders()), response.read()
connection.close()
return result
try:
status, headers, _ = request("/")
self.assertEqual(status, 303)
cookie = headers["Set-Cookie"].split(";", 1)[0]
status, _, body = request("/api/v1/session", cookie)
self.assertEqual(status, 200)
session = json.loads(body)
self.assertTrue(session["library_load_required"])
self.assertIsNone(session["active_workspace_id"])
self.assertFalse(root.exists())
status, _, body = request("/api/v1/library", cookie)
self.assertEqual(status, 200)
self.assertEqual(json.loads(body)["entries"], [])
self.assertTrue(root.is_dir())
self.assertTrue((root / "workspaces").is_dir())
finally:
server.shutdown()
server.server_close()
thread.join(3)
def _assert_resumed_once(self, expected_workspace_id, response): def _assert_resumed_once(self, expected_workspace_id, response):
self.assertEqual(response["workspace_id"], expected_workspace_id) self.assertEqual(response["workspace_id"], expected_workspace_id)
self.assertEqual(len(list(self.library.workspaces.iterdir())), 1) self.assertEqual(len(list(self.library.workspaces.iterdir())), 1)

View file

@ -178,8 +178,12 @@ def serve(args):
instance_id = secrets.token_urlsafe(24) instance_id = secrets.token_urlsafe(24)
library = None library = None
if selection[0] == "library": if selection[0] == "library":
library = WebLibrary(selection[1], args.bridge) library = WebLibrary(selection[1], args.bridge, lazy=args.lazy_library)
workspace = library.root / ".inactive" # WHY: a persistent service must answer HTTP before touching the
# configured investigation library. Its inactive workspace belongs to
# private runtime state until a human opens a selected workspace.
workspace = (files.runtime / "inactive" if args.lazy_library
else library.root / ".inactive")
workspace.mkdir(mode=0o700, exist_ok=True) workspace.mkdir(mode=0o700, exist_ok=True)
else: else:
workspace = selection[1] workspace = selection[1]
@ -191,7 +195,8 @@ def serve(args):
automatic_session=args.automatic_session, agent_mode=args.agent_mode, automatic_session=args.automatic_session, agent_mode=args.agent_mode,
agent_endpoint=args.agent_endpoint, agent_model=args.agent_model, agent_endpoint=args.agent_endpoint, agent_model=args.agent_model,
agent_timeout=args.agent_timeout, agent_autostart=args.agent_autostart, agent_timeout=args.agent_timeout, agent_autostart=args.agent_autostart,
agent_config_path=args.agent_config_path) agent_config_path=args.agent_config_path,
public_local_origin=args.public_local_origin)
except OSError as error: except OSError as error:
_handshake(args.handshake_fd, {"ok": False, _handshake(args.handshake_fd, {"ok": False,
"error": f"Port indisponible : {error}"}) "error": f"Port indisponible : {error}"})
@ -269,6 +274,10 @@ def start(args):
command.extend(("--agent-mode", args.agent_mode)) command.extend(("--agent-mode", args.agent_mode))
if args.agent_autostart: if args.agent_autostart:
command.append("--agent-autostart") command.append("--agent-autostart")
if args.lazy_library:
command.append("--lazy-library")
if args.public_local_origin is not None:
command.extend(("--public-local-origin", args.public_local_origin))
if args.agent_config_path is not None: if args.agent_config_path is not None:
command.extend(("--agent-config-path", str(args.agent_config_path))) command.extend(("--agent-config-path", str(args.agent_config_path)))
if args.agent_endpoint is not None: if args.agent_endpoint is not None:
@ -372,6 +381,10 @@ def build_parser():
help="démarre le seul llama-server configuré dans XDG") help="démarre le seul llama-server configuré dans XDG")
command.add_argument("--agent-config-path", type=Path, command.add_argument("--agent-config-path", type=Path,
help="configuration agent explicite et non versionnée") help="configuration agent explicite et non versionnée")
command.add_argument("--lazy-library", action="store_true",
help="attend une action UI avant de lire la bibliothèque")
command.add_argument("--public-local-origin", choices=("http://invest.labfy",),
help="origine navigateur locale exacte servie par nginx")
if name == "start": if name == "start":
command.add_argument("--open-browser", action="store_true", command.add_argument("--open-browser", action="store_true",
help="ouvre l'interface locale après vérification") help="ouvre l'interface locale après vérification")

View file

@ -8,6 +8,7 @@ import hashlib
import json import json
import os import os
import subprocess import subprocess
import threading
import uuid import uuid
from pathlib import Path from pathlib import Path
@ -21,16 +22,32 @@ class LibraryError(ValueError):
class WebLibrary: class WebLibrary:
def __init__(self, root: Path, bridge: Path): def __init__(self, root: Path, bridge: Path, *, lazy=False):
if not isinstance(lazy, bool):
raise TypeError("Mode de bibliothèque invalide")
self.root = Path(root).absolute() self.root = Path(root).absolute()
self.bridge = Path(bridge).resolve() self.bridge = Path(bridge).resolve()
self._prepare_private_root() self.lazy = lazy
self._prepare_lock = threading.Lock()
self._ready = False
self.workspaces = self.root / "workspaces" 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.registry_path = self.root / "registry.json"
self.creation_intent_path = self.root / "creation-intent.json" self.creation_intent_path = self.root / "creation-intent.json"
self.lock_path = self.root / "registry.lock" self.lock_path = self.root / "registry.lock"
if not lazy:
self._ensure_ready()
def _ensure_ready(self):
# INVARIANT: lazy construction performs no stat, mkdir or registry read
# under the selected library. Only an explicit list/create/open call
# initializes it, once, before the existing locked operations run.
with self._prepare_lock:
if self._ready:
return
self._prepare_private_root()
self.workspaces.mkdir(mode=0o700, exist_ok=True)
self._assert_plain_directory(self.workspaces)
self._ready = True
def _prepare_private_root(self): def _prepare_private_root(self):
for component in reversed((self.root, *self.root.parents)): for component in reversed((self.root, *self.root.parents)):
@ -50,6 +67,7 @@ class WebLibrary:
raise LibraryError(f"Répertoire de bibliothèque invalide : {path}") raise LibraryError(f"Répertoire de bibliothèque invalide : {path}")
def _locked(self): def _locked(self):
self._ensure_ready()
descriptor = os.open(self.lock_path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600) descriptor = os.open(self.lock_path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
os.fchmod(descriptor, 0o600) os.fchmod(descriptor, 0o600)
fcntl.flock(descriptor, fcntl.LOCK_EX) fcntl.flock(descriptor, fcntl.LOCK_EX)

View file

@ -72,23 +72,31 @@ class WorkspaceServer(ThreadingHTTPServer):
research_fixture_authority=None, automatic_session=False, research_fixture_authority=None, automatic_session=False,
agent_mode="deterministic-demo", agent_endpoint=None, agent_mode="deterministic-demo", agent_endpoint=None,
agent_model=None, agent_timeout=10.0, agent_autostart=False, agent_model=None, agent_timeout=10.0, agent_autostart=False,
agent_config_path=None): agent_config_path=None, public_local_origin=None):
if (not isinstance(session_ttl_seconds, int) or if (not isinstance(session_ttl_seconds, int) or
isinstance(session_ttl_seconds, bool) or session_ttl_seconds <= 0): isinstance(session_ttl_seconds, bool) or session_ttl_seconds <= 0):
raise ValueError("Durée de session invalide") raise ValueError("Durée de session invalide")
if public_local_origin not in (None, "http://invest.labfy"):
raise ValueError("Origine locale publique invalide")
super().__init__(address, handler) super().__init__(address, handler)
self.workspace = workspace.resolve() self.workspace = workspace.resolve()
self.bridge = bridge.resolve() self.bridge = bridge.resolve()
self.authority = f"127.0.0.1:{self.server_port}" self.authority = f"127.0.0.1:{self.server_port}"
self.origin = f"http://{self.authority}" self.origin = f"http://{self.authority}"
# CONTRACT: the only alternate browser origin is the explicitly chosen
# local nginx hostname. It never authorizes a forwarded host or LAN bind.
self.public_local_origin = public_local_origin
# CONTRACT: le poste Web ne possède plus de parcours code. Toute # CONTRACT: le poste Web ne possède plus de parcours code. Toute
# première navigation loopback établit seulement un cookie HttpOnly ; # première navigation loopback établit seulement un cookie HttpOnly ;
# Host, Origin et CSRF restent exigés pour chaque mutation. # Host, Origin et CSRF restent exigés pour chaque mutation.
self.automatic_session = True self.automatic_session = True
self.library = library self.library = library
self.active_workspace_id = None self.active_workspace_id = None
# CONTRACT: the persistent lazy service must not enumerate or read the
# configured library during server construction. Its initial generation
# is only a placeholder until the user explicitly requests the list.
self.library_generation = (library.snapshot(None)["generation"] self.library_generation = (library.snapshot(None)["generation"]
if library is not None else 0) if library is not None and not library.lazy else 0)
self.instance_id = instance_id or secrets.token_urlsafe(18) self.instance_id = instance_id or secrets.token_urlsafe(18)
self.config_id = config_id or hashlib.sha256( self.config_id = config_id or hashlib.sha256(
str(self.workspace).encode()).hexdigest() str(self.workspace).encode()).hexdigest()
@ -1092,7 +1100,17 @@ class Handler(BaseHTTPRequestHandler):
"error": code, "message": message}) "error": code, "message": message})
def _request_valid(self): def _request_valid(self):
return self.headers.get("Host") == self.server.authority host = self.headers.get("Host")
return (host == self.server.authority or
(self.server.public_local_origin == "http://invest.labfy" and
host == "invest.labfy" and self.client_address[0] == "127.0.0.1"))
def _request_origin(self):
# INVARIANT: an accepted Origin is derived from the validated Host,
# never from Forwarded or X-Forwarded-* supplied by a client.
if self.headers.get("Host") == "invest.labfy":
return self.server.public_local_origin
return self.server.origin
def _authenticated(self): def _authenticated(self):
with self.server.session_lock: with self.server.session_lock:
@ -1106,7 +1124,7 @@ class Handler(BaseHTTPRequestHandler):
def _mutation_allowed(self): def _mutation_allowed(self):
with self.server.session_lock: with self.server.session_lock:
return (self._authenticated() and return (self._authenticated() and
self.headers.get("Origin") == self.server.origin and self.headers.get("Origin") == self._request_origin() and
hmac.compare_digest(self.headers.get("X-Labfy-CSRF", ""), hmac.compare_digest(self.headers.get("X-Labfy-CSRF", ""),
self.server.csrf)) self.server.csrf))
@ -1406,12 +1424,14 @@ class Handler(BaseHTTPRequestHandler):
except (OSError,ValueError,json.JSONDecodeError) as error: except (OSError,ValueError,json.JSONDecodeError) as error:
self._error(HTTPStatus.CONFLICT,"workspace_incomplete",str(error));return self._error(HTTPStatus.CONFLICT,"workspace_incomplete",str(error));return
self._json(HTTPStatus.OK, {"contract": "labfy.workspace.session.v1", self._json(HTTPStatus.OK, {"contract": "labfy.workspace.session.v1",
"csrf": self.server.csrf, "origin": self.server.origin, "csrf": self.server.csrf, "origin": self._request_origin(),
"workspace_state": "READY" if context else "EMPTY", "workspace_state": "READY" if context else "EMPTY",
"investigation_id": context.get("investigation_id") if context else None, "investigation_id": context.get("investigation_id") if context else None,
"title": context.get("title", "SPECIMEN") 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, "library_mode": self.server.library is not None,
"library_load_required": (self.server.library is not None and
self.server.library.lazy),
"active_workspace_id": self.server.active_workspace_id, "active_workspace_id": self.server.active_workspace_id,
"generation": self.server.library_generation}) "generation": self.server.library_generation})
elif path == "/api/v1/library": elif path == "/api/v1/library":

View file

@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""Ajoute le seul hôte Labfy local à nginx et hosts, puis valide et recharge."""
from __future__ import annotations
import os
import secrets
import subprocess
import sys
from pathlib import Path
NGINX = Path("/etc/nginx/nginx.conf")
HOSTS = Path("/etc/hosts")
BLOCK = Path(__file__).resolve().parents[1] / "packaging/nginx/invest.labfy.conf"
def render_nginx(current: str, block: str) -> str:
# CONTRACT: preserve the existing Trainlog server byte for byte. A second
# differing invest.labfy block is a conflict, not an invitation to replace it.
if "server_name trainlog.perf;" not in current:
raise ValueError("bloc Trainlog attendu absent")
if "server_name invest.labfy;" in current:
if block.strip() not in current:
raise ValueError("bloc invest.labfy existant différent")
return current
if not current.rstrip().endswith("}"):
raise ValueError("fin du bloc http nginx inattendue")
end = len(current.rstrip()) - 1
return current[:end] + "\n" + block.rstrip() + "\n" + current[end:]
def render_hosts(current: str) -> str:
# INVARIANT: a conflicting resolver entry must be reconciled by the owner;
# never append a competing mapping that depends on resolver order.
found = False
for line in current.splitlines():
fields = line.split("#", 1)[0].split()
if "invest.labfy" in fields[1:]:
if fields[0] != "127.0.0.1":
raise ValueError("résolution invest.labfy conflictuelle")
found = True
if found:
return current
return current.rstrip("\n") + "\n127.0.0.1 invest.labfy\n"
def write_atomic(path: Path, data: bytes) -> None:
temporary = path.with_name(path.name + ".labfy-new-" + secrets.token_hex(8))
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o644)
try:
with os.fdopen(descriptor, "wb") as stream:
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
temporary.replace(path)
finally:
temporary.unlink(missing_ok=True)
def save_backup(path: Path, data: bytes) -> Path:
backup = path.with_name(path.name + ".labfy-pre-invest-" + secrets.token_hex(8))
descriptor = os.open(backup, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
with os.fdopen(descriptor, "wb") as stream:
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
return backup
def run(argv: list[str]) -> None:
subprocess.run(argv, check=True)
def main() -> int:
if os.geteuid() != 0:
raise ValueError("exécution root requise")
if any(path.is_symlink() or not path.is_file() for path in (NGINX, HOSTS)):
raise ValueError("fichier système non régulier ou lien symbolique")
before_nginx = NGINX.read_bytes()
before_hosts = HOSTS.read_bytes()
nginx = render_nginx(before_nginx.decode(), BLOCK.read_text())
hosts = render_hosts(before_hosts.decode())
if nginx.encode() == before_nginx and hosts.encode() == before_hosts:
run(["/usr/bin/nginx", "-t"])
print("configuration déjà appliquée et valide")
return 0
backups = []
try:
if nginx.encode() != before_nginx:
backups.append(save_backup(NGINX, before_nginx))
if hosts.encode() != before_hosts:
backups.append(save_backup(HOSTS, before_hosts))
if nginx.encode() != before_nginx:
write_atomic(NGINX, nginx.encode())
if hosts.encode() != before_hosts:
write_atomic(HOSTS, hosts.encode())
# CONTRACT: nginx is reloaded only after the real root-owned config
# passes nginx -t; failed validation restores both original files.
run(["/usr/bin/nginx", "-t"])
run(["/usr/bin/systemctl", "reload", "nginx"])
except Exception:
if backups:
write_atomic(NGINX, before_nginx)
write_atomic(HOSTS, before_hosts)
raise
print("Configuration appliquée. Sauvegardes :", *(str(path) for path in backups))
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except (OSError, ValueError, subprocess.CalledProcessError) as error:
print(f"Application root refusée : {error}", file=sys.stderr)
sys.exit(1)

19
scripts/labfy-investigation-web Executable file
View file

@ -0,0 +1,19 @@
#!/bin/sh
# CONTRACT: systemd owns this foreground process; the configured library is
# never selected implicitly and no shell fragment is evaluated from web.env.
set -eu
: "${LABFY_LIBRARY:?LABFY_LIBRARY doit désigner une bibliothèque explicite}"
: "${LABFY_WEB_PORT:?LABFY_WEB_PORT doit désigner le port loopback dédié}"
case "$LABFY_LIBRARY" in
/*) ;;
*) echo "LABFY_LIBRARY doit être un chemin absolu" >&2; exit 2 ;;
esac
case "$LABFY_WEB_PORT" in
*[!0-9]*|'') echo "LABFY_WEB_PORT invalide" >&2; exit 2 ;;
esac
script=$(readlink -f -- "$0")
repository=$(dirname -- "$(dirname -- "$script")")
exec /usr/bin/python3 "$repository/prototypes/web-graph/web_app.py" serve \
--library "$LABFY_LIBRARY" --lazy-library --port "$LABFY_WEB_PORT" \
--automatic-session --public-local-origin http://invest.labfy \
--agent-mode local-model --agent-autostart --agent-timeout 90