feat(web): deploy local investigation workbench with Qwen
This commit is contained in:
parent
537ad97f79
commit
f72a192d4d
20 changed files with 532 additions and 41 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
16
packaging/nginx/invest.labfy.conf
Normal file
16
packaging/nginx/invest.labfy.conf
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
packaging/systemd/labfy-investigation-web.service
Normal file
22
packaging/systemd/labfy-investigation-web.service
Normal 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
|
||||||
87
prototypes/web-graph/public/app.js
vendored
87
prototypes/web-graph/public/app.js
vendored
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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…"
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
80
prototypes/web-graph/tests/test_lazy_library_browser.mjs
Normal file
80
prototypes/web-graph/tests/test_lazy_library_browser.mjs
Normal 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 });
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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":
|
||||||
|
|
|
||||||
115
scripts/apply-local-web-root.py
Normal file
115
scripts/apply-local-web-root.py
Normal 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
19
scripts/labfy-investigation-web
Executable 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
|
||||||
Loading…
Reference in a new issue