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
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 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

View file

@ -10,6 +10,12 @@
> L'exécution intrusive reste `LAB-ONLY TARGET`; Tool Auto-Provisioning,
> 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
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
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
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
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
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
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).
## 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 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) {
if (value?.contract === "labfy.web_graph.error.v1") {
return {
@ -416,7 +427,7 @@ async function startAgentMission() {
max_tool_calls: 16,
},
human_confirmed: true,
idempotency_key: crypto.randomUUID(),
idempotency_key: newUuid(),
});
renderAgentMission(value);
appendAgentActivity({
@ -433,7 +444,7 @@ async function cancelAgentMission() {
workspace_id: workspaceContext.workspaceId,
mission_id: missionId,
human_confirmed: true,
idempotency_key: crypto.randomUUID(),
idempotency_key: newUuid(),
});
renderAgentMission(value);
appendAgentActivity({
@ -461,11 +472,11 @@ async function decideAgentProposal(record, action) {
`/api/v1/agent-proposals/${encodeURIComponent(record.proposal_id)}/${action}`,
{
workspace_id: workspaceContext.workspaceId,
decision_id: crypto.randomUUID(),
decision_id: newUuid(),
reason: "Décision explicite depuis l’interface locale",
decided_by: "opérateur local",
decided_at: new Date().toISOString(),
idempotency_key: crypto.randomUUID(),
idempotency_key: newUuid(),
},
);
await refreshAgentProposals();
@ -542,9 +553,9 @@ async function loadAgentRuntime() {
if (value.mode === "DETERMINISTIC_DEMO") return false;
agentMode = "LOCAL";
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-system-qwen").textContent = value.model ?? "disponible";
byId("agent-system-qwen").textContent = "prêt";
} else {
byId("agent-status").textContent =
`Modèle local indisponible · ${shortAgentReason(value.reason)}`;
@ -754,6 +765,10 @@ async function refreshAgentEvents() {
clearInterval(agentEventTimer);
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"));
} finally {
agentEventsRefreshing = false;
@ -784,8 +799,9 @@ async function callAgentTool() {
const path = agentMode === "LOCAL"
? "/api/v1/agent-runtime/turns" : "/api/v1/agent-tools/turns";
const body = agentMode === "LOCAL"
? { objective, idempotency_key: crypto.randomUUID() } : { objective };
? { objective, idempotency_key: newUuid() } : { objective };
const admitted = await postCommand(path, body);
if (agentMode === "LOCAL") byId("agent-system-qwen").textContent = "occupé";
activeAgentTurnId = admitted.turn_id;
agentTurnState = admitted.state ?? "QUEUED";
agentEventCursor = 0;
@ -829,7 +845,7 @@ async function evidenceJson(path) {
function reviewEnvelope(observation, reason) {
return {
operation_id: crypto.randomUUID(),
operation_id: newUuid(),
expected_revision: String(observation.revision),
author: "opérateur local",
reason,
@ -986,7 +1002,7 @@ function configureImport() {
// CONTRACT: la sélection reste multiple, mais le client respecte la
// concurrence serveur au lieu de transformer le troisième fichier en
// 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);
event.target.value="";
});
@ -994,7 +1010,7 @@ function configureImport() {
byId("import-confirm").disabled=true;
for(const [id,prepared] of [...preparedUploads]){
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"});
preparedUploads.delete(id);byId("import-status").textContent=`${prepared.name} importé avec l’UUID ${prepared.evidence_id}.`;
}catch(error){byId("import-status").textContent=error.message;}
@ -1358,7 +1374,7 @@ async function prepareResearch() {
selection_ids: [node.id],
question: byId("research-question").value.trim(),
exclusions,
idempotency_key: crypto.randomUUID(),
idempotency_key: newUuid(),
});
byId("research-note").textContent =
"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,
decisions,
exclusions,
idempotency_key: crypto.randomUUID(),
idempotency_key: newUuid(),
});
const grantId = grant.grants[0]?.grant_id;
if (!grantId) throw new Error("Autorisation durable absente.");
@ -1390,7 +1406,7 @@ async function launchResearch() {
grant_id: grantId,
input_revision: grant.input_revision,
action_ids: actionIds,
idempotency_key: crypto.randomUUID(),
idempotency_key: newUuid(),
});
// 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.
@ -1839,7 +1855,7 @@ async function generateReport() {
if (!reportPreview) return;
const admission = await postCommand("/api/v1/reports", {
preview_revision: reportPreview.revision,
idempotency_key: crypto.randomUUID(),
idempotency_key: newUuid(),
});
byId("report-status").textContent = "Génération locale en cours…";
for (let i = 0; i < 100; i++) {
@ -1897,7 +1913,7 @@ async function runCapability(capability) {
if (button) button.disabled = true;
const storageKey = `labfy-intent:${workspaceContext.workspaceId}:` +
`${workspaceContext.generation}:${node.object_id}:${capability.id}`;
const key = sessionStorage.getItem(storageKey) ?? crypto.randomUUID();
const key = sessionStorage.getItem(storageKey) ?? newUuid();
sessionStorage.setItem(storageKey, key);
try {
await postCommand("/api/v1/jobs", {
@ -2442,24 +2458,31 @@ function renderLibrary(value) {
async function refreshLibrary() {
const value = await libraryRequest("/api/v1/library", { cache: "no-store" });
renderLibrary(value);
byId("library-load").hidden = true;
byId("workspace-create").hidden = false;
byId("library-list-section").hidden = false;
return value;
}
async function showLibrary({ focus = false } = {}) {
async function showLibrary({ focus = false, load = true } = {}) {
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("workspace-create").hidden = !load;
byId("library-list-section").hidden = !load;
byId("library-load").hidden = load;
byId("library-title").textContent = "Bibliothèque d’enquêtes";
byId("library-connection").textContent = "Actualisation…";
try {
await refreshLibrary();
} catch (error) {
byId("library-connection").textContent = error.message;
byId("library-connection").textContent = load
? "Actualisation…" : "Bibliothèque non chargée";
if (load) {
try {
await refreshLibrary();
} catch (error) {
byId("library-connection").textContent = error.message;
}
}
if (focus) byId("library-title").focus();
}
@ -2654,6 +2677,18 @@ function configureApplication() {
}
});
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) => {
event.preventDefault();
const button = event.currentTarget.querySelector("button");
@ -2662,7 +2697,7 @@ function configureApplication() {
try {
const title = byId("workspace-title").value.trim();
if (pendingCreateIntent?.title !== title)
pendingCreateIntent = { title, idempotencyKey: crypto.randomUUID() };
pendingCreateIntent = { title, idempotencyKey: newUuid() };
const path = libraryMode ? "/api/v1/library/workspaces" : "/api/v1/workspace";
const body = libraryMode
? { title, idempotency_key: pendingCreateIntent.idempotencyKey }
@ -2731,7 +2766,7 @@ function configureApplication() {
...byId("planner-list").querySelectorAll("input:checked"),
].map((input) => input.value);
if (!ids.length || !plannerValue) return;
const key = crypto.randomUUID();
const key = newUuid();
try {
await postCommand("/api/v1/plans", {
recommendation_ids: ids,
@ -2782,7 +2817,7 @@ async function start() {
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();
if (libraryMode) await showLibrary({ load: session?.library_load_required !== true });
else if (session?.workspace_state === "EMPTY") showLegacyCreation();
else await activateLegacyWorkspace(session);
} 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>
<span id="library-connection" role="status">Chargement…</span>
</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">
<h2 id="create-title">Nouvelle enquête</h2>
<p>Crée un espace local vide. Aucun contenu d’exemple n’est ajouté.</p>
@ -92,7 +93,7 @@
</nav>
<main class="workbench">
<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">
<span>Objectif utilisateur</span>
<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
// la preuve E2E : données SPECIMEN, rapport, bibliothèque, redémarrages et A/B.
"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
// 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.

View file

@ -54,6 +54,40 @@ class AutomaticSessionTest(unittest.TestCase):
"Content-Type": "application/json", "Content-Length": "2"}, "{}")
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__":
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 openAutomaticSession(page, origin);
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';
await page.type("#agent-prompt", hostile);

View file

@ -111,7 +111,7 @@ try {
await page.setViewport({ width: 1360, height: 980 });
const session = await openAutomaticSession(page, origin, "agent opérationnel");
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),
"indisponible");
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"))
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):
self.assertEqual(response["workspace_id"], expected_workspace_id)
self.assertEqual(len(list(self.library.workspaces.iterdir())), 1)

View file

@ -178,8 +178,12 @@ def serve(args):
instance_id = secrets.token_urlsafe(24)
library = None
if selection[0] == "library":
library = WebLibrary(selection[1], args.bridge)
workspace = library.root / ".inactive"
library = WebLibrary(selection[1], args.bridge, lazy=args.lazy_library)
# 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)
else:
workspace = selection[1]
@ -191,7 +195,8 @@ def serve(args):
automatic_session=args.automatic_session, agent_mode=args.agent_mode,
agent_endpoint=args.agent_endpoint, agent_model=args.agent_model,
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:
_handshake(args.handshake_fd, {"ok": False,
"error": f"Port indisponible : {error}"})
@ -269,6 +274,10 @@ def start(args):
command.extend(("--agent-mode", args.agent_mode))
if args.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:
command.extend(("--agent-config-path", str(args.agent_config_path)))
if args.agent_endpoint is not None:
@ -372,6 +381,10 @@ def build_parser():
help="démarre le seul llama-server configuré dans XDG")
command.add_argument("--agent-config-path", type=Path,
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":
command.add_argument("--open-browser", action="store_true",
help="ouvre l'interface locale après vérification")

View file

@ -8,6 +8,7 @@ import hashlib
import json
import os
import subprocess
import threading
import uuid
from pathlib import Path
@ -21,16 +22,32 @@ class LibraryError(ValueError):
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.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.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"
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):
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}")
def _locked(self):
self._ensure_ready()
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)

View file

@ -72,23 +72,31 @@ class WorkspaceServer(ThreadingHTTPServer):
research_fixture_authority=None, automatic_session=False,
agent_mode="deterministic-demo", agent_endpoint=None,
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
isinstance(session_ttl_seconds, bool) or session_ttl_seconds <= 0):
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)
self.workspace = workspace.resolve()
self.bridge = bridge.resolve()
self.authority = f"127.0.0.1:{self.server_port}"
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
# première navigation loopback établit seulement un cookie HttpOnly ;
# Host, Origin et CSRF restent exigés pour chaque mutation.
self.automatic_session = True
self.library = library
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"]
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.config_id = config_id or hashlib.sha256(
str(self.workspace).encode()).hexdigest()
@ -1092,7 +1100,17 @@ class Handler(BaseHTTPRequestHandler):
"error": code, "message": message})
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):
with self.server.session_lock:
@ -1106,7 +1124,7 @@ class Handler(BaseHTTPRequestHandler):
def _mutation_allowed(self):
with self.server.session_lock:
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", ""),
self.server.csrf))
@ -1406,12 +1424,14 @@ class Handler(BaseHTTPRequestHandler):
except (OSError,ValueError,json.JSONDecodeError) as error:
self._error(HTTPStatus.CONFLICT,"workspace_incomplete",str(error));return
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",
"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",
"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,
"generation": self.server.library_generation})
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