feat(web): open v0.1.0 web-only investigation workbench

This commit is contained in:
fy59 2026-09-27 19:32:35 +02:00
parent 9495e1258a
commit 7053bcc345
29 changed files with 797 additions and 364 deletions

View file

@ -392,7 +392,7 @@ explicitement autorisée :
make web-workspace-local WORKSPACE=/tmp/labfy-local-workspace make web-workspace-local WORKSPACE=/tmp/labfy-local-workspace
``` ```
Après saisie du code éphémère, créer l’enquête puis utiliser **Ajouter des Le workbench s’ouvre directement ; créer l’enquête puis utiliser **Ajouter des
preuves** pour sélectionner des EML, PNG ou JPEG (4 Mio par fichier). La preuves** pour sélectionner des EML, PNG ou JPEG (4 Mio par fichier). La
réception, la confirmation, le planner, les analyses, le graphe et les rapports réception, la confirmation, le planner, les analyses, le graphe et les rapports
restent séparés. La réouverture reprend le même UUID sans exemple ni duplication. restent séparés. La réouverture reprend le même UUID sans exemple ni duplication.

View file

@ -7,15 +7,17 @@
## Parcours ## Parcours
La commande principale lance l’API loopback sur une bibliothèque locale La commande principale lance l’API loopback sur une bibliothèque locale
explicitement choisie. Elle affiche l’URL et un code de session éphémère : explicitement choisie, ouvre le navigateur et affiche directement le workbench :
```sh ```sh
make web PORT=8081 LIBRARY=/chemin/vers/la-bibliotheque-locale make web PORT=8081 LIBRARY=/chemin/vers/la-bibliotheque-locale
``` ```
L'instance écoute sur `127.0.0.1:8081` dans cet exemple. Après authentification, L'instance écoute sur `127.0.0.1:8081` dans cet exemple. La navigation racine
la bibliothèque permet de créer une enquête locale par le bridge C ou d'ouvrir établit automatiquement une session locale HttpOnly et ouvre directement la
une enquête enregistrée. Une seule enquête peut être active par instance ; une bibliothèque, sans formulaire ni code de session. La bibliothèque permet de
créer une enquête locale par le bridge C ou d'ouvrir une enquête enregistrée.
Une seule enquête peut être active par instance ; une
autre ouverture exige le redémarrage du poste local. L'utilisateur sélectionne autre ouverture exige le redémarrage du poste local. L'utilisateur sélectionne
ensuite l’EML ou l’image dans le graphe et choisit « Analyser les en-têtes » ou ensuite l’EML ou l’image dans le graphe et choisit « Analyser les en-têtes » ou
« Examiner les métadonnées ». L’intention est persistée avant la réponse HTTP « Examiner les métadonnées ». L’intention est persistée avant la réponse HTTP

View file

@ -137,6 +137,29 @@ capabilities par objet, provenance multiple, deux reconnexions SSE, conservation
du contexte, doublon, trou et rattrapage snapshot. Les captures sont des preuves du contexte, doublon, trou et rattrapage snapshot. Les captures sont des preuves
visuelles complémentaires ; les assertions d'interaction font foi. visuelles complémentaires ; les assertions d'interaction font foi.
### Relevé du polish visuel Web V1 du 27 septembre 2026
La coque Web actuelle est organisée autour de **Agent | Graphe | Activité** et
d’un tiroir transversal. Le graphe reste la surface dominante. Les panneaux
latéraux et le tiroir sont repliables ; sur desktop, leurs dimensions sont
redimensionnables et conservées comme préférences locales par origine. Cette
préférence ne contient ni données d’enquête, ni token, ni état métier.
L’Agent affiche un flux SPECIMEN typé, une demande d’autorisation explicitant
cible, provider, données sortantes, action, exposition, budget et raison. Il
indique clairement qu’aucun modèle local n’est connecté : cette surface prépare
un raccordement futur, elle ne simule aucun appel IA. Activité est un terminal
structuré filtrable ; son mode expert ne révèle que des paramètres SPECIMEN non
secrets et une provenance bornée.
test_visual_polish_browser.mjs, inclus dans npm run test:browser, exerce le
resize et sa restauration, les panneaux repliés, le prompt Ctrl+Entrée, le
filtre/inspecteur d’activité, le drag/pin, le mode graphe prioritaire, les
tailles 1440×900, 1280×800, 1024×768 et 700×900, ainsi qu’un diagnostic
d’erreur de contrat. Il produit des captures inspectables sous
/tmp/labfy-visual-polish-*.png, toutes sur workspace et serveur loopback
temporaires SPECIMEN.
### Relevé du lot J3 local du 26 septembre 2026 ### Relevé du lot J3 local du 26 septembre 2026
| Validation | Résultat observé | | Validation | Résultat observé |

View file

@ -187,18 +187,9 @@ function expireAuthenticatedSession() {
byId("library-home").hidden = false; byId("library-home").hidden = false;
byId("workspace-create").hidden = true; byId("workspace-create").hidden = true;
byId("library-list-section").hidden = true; byId("library-list-section").hidden = true;
byId("library-title").textContent = "Session locale expirée"; // CONTRACT: la reconnexion automatique ne rejoue aucune mutation. La
byId("library-connection").textContent = // navigation racine obtient un nouveau cookie HttpOnly local sans code ni URL.
"Le travail en cours a été arrêté. Reconnectez-vous pour reprendre ; aucune action refusée n’a été relancée."; window.location.replace("/");
let reconnect = byId("session-reconnect");
if (!reconnect) {
reconnect = document.createElement("a");
reconnect.id = "session-reconnect";
reconnect.href = "/";
reconnect.textContent = "Se reconnecter";
byId("library-connection").insertAdjacentElement("afterend", reconnect);
}
reconnect.focus();
} }
async function detectExpiredSession(response) { async function detectExpiredSession(response) {
@ -282,11 +273,20 @@ function invalidateReportPreview(
} }
async function postCommand(path, value = {}) { async function postCommand(path, value = {}) {
const response = await workspaceFetch(path, { const request = () => workspaceFetch(path, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", "X-Labfy-CSRF": csrfToken }, headers: { "Content-Type": "application/json", "X-Labfy-CSRF": csrfToken },
body: JSON.stringify(value), body: JSON.stringify(value),
}); });
let response = await request();
// WHY: deux intentions humaines liées (préparer puis autoriser) peuvent se
// succéder plus vite que le garde-fou local anti-burst. Une réponse 429 n'a
// admis aucune mutation ; un unique backoff borné rejoue donc la même
// intention, avec sa clé d'idempotence lorsqu'elle est requise.
if (response.status === 429) {
await new Promise((resolve) => setTimeout(resolve, 35));
response = await request();
}
const result = await workspaceJson(response); const result = await workspaceJson(response);
if (!response.ok) throw new Error(result.message ?? "Commande refusée"); if (!response.ok) throw new Error(result.message ?? "Commande refusée");
return result; return result;
@ -995,6 +995,7 @@ function render({ focusDomNodeId = null } = {}) {
offset, offset,
focusNodeId: visibleIds.has(activeNodeId) ? activeNodeId : null, focusNodeId: visibleIds.has(activeNodeId) ? activeNodeId : null,
}); });
byId("graph-zoom").textContent = `${Math.round(scale * 100)} %`;
renderObjectList(); renderObjectList();
renderSelection(); renderSelection();
} }
@ -1025,6 +1026,13 @@ function renderSelection() {
details.textContent = "Sélectionnez un objet."; details.textContent = "Sélectionnez un objet.";
actions.textContent = "Aucune action sans sélection valide."; actions.textContent = "Aucune action sans sélection valide.";
byId("provenance-mirror").textContent = "Sélectionnez un objet."; byId("provenance-mirror").textContent = "Sélectionnez un objet.";
byId("drawer-details-summary").textContent =
"Sélectionnez un objet du graphe pour conserver son contexte ici.";
byId("drawer-provenance-summary").textContent = "Aucune source sélectionnée.";
byId("drawer-timeline-summary").textContent =
"La chronologie pertinente apparaît avec l’objet sélectionné.";
byId("drawer-observations-summary").textContent =
"Aucune observation à promouvoir automatiquement.";
return; return;
} }
@ -1074,6 +1082,16 @@ function renderSelection() {
if (!provenanceMirror.hasChildNodes()) if (!provenanceMirror.hasChildNodes())
provenanceMirror.textContent = "Aucune origine supplémentaire persistée."; provenanceMirror.textContent = "Aucune origine supplémentaire persistée.";
renderActions(actions, node); renderActions(actions, node);
byId("drawer-details-summary").textContent =
`${node.label} · ${node.type} · état ${node.state}`;
byId("drawer-provenance-summary").textContent =
`Provenance disponible pour ${node.label}. Utilisez l’onglet Provenance de l’activité pour parcourir les origines.`;
byId("drawer-timeline-summary").textContent =
`La timeline filtre les événements attribués à ${node.label} sans inventer de date.`;
byId("drawer-observations-summary").textContent =
node.object_kind === "observation"
? `${node.label} est une observation : revue explicite requise.`
: `Aucune observation n’est automatiquement déduite de ${node.label}.`;
} }
function renderProvenance(container, nodeId, sectionId) { function renderProvenance(container, nodeId, sectionId) {
@ -1484,6 +1502,75 @@ function updateProjectionControls() {
} }
} }
function configureResizableLayout() {
// CONTRACT: les dimensions de panneau sont des préférences locales par
// origine. Elles ne modifient ni le snapshot, ni les positions épinglées.
const workbench = document.querySelector(".workbench");
const dimensions = [
{ id: "agent-resizer", property: "--agent-pane", key: "agent", axis: "x",
minimum: 208, maximum: 460, direction: 1 },
{ id: "inspector-resizer", property: "--inspector-pane", key: "inspector", axis: "x",
minimum: 256, maximum: 520, direction: -1 },
{ id: "drawer-resizer", property: "--drawer", key: "drawer", axis: "y",
minimum: 130, maximum: 440, direction: -1 },
];
for (const dimension of dimensions) {
const stored = Number.parseInt(localStorage.getItem(`labfy-layout:${dimension.key}`), 10);
if (Number.isFinite(stored))
workbench.style.setProperty(dimension.property, `${stored}px`);
const separator = byId(dimension.id);
const applyKeyboardDelta = (delta) => {
const current = Number.parseInt(
getComputedStyle(workbench).getPropertyValue(dimension.property), 10,
);
const next = Math.max(dimension.minimum, Math.min(dimension.maximum, current + delta));
workbench.style.setProperty(dimension.property, `${next}px`);
localStorage.setItem(`labfy-layout:${dimension.key}`, String(next));
requestAnimationFrame(() => renderer.updateViewport(scale, offset));
};
separator.addEventListener("keydown", (event) => {
const backward = dimension.axis === "x" ? "ArrowLeft" : "ArrowUp";
const forward = dimension.axis === "x" ? "ArrowRight" : "ArrowDown";
if (![backward, forward].includes(event.key)) return;
event.preventDefault();
applyKeyboardDelta((event.key === forward ? 16 : -16) * dimension.direction);
});
separator.addEventListener("pointerdown", (event) => {
if (matchMedia("(max-width: 900px)").matches) return;
const initial = Number.parseInt(
getComputedStyle(workbench).getPropertyValue(dimension.property), 10,
);
const origin = dimension.axis === "x" ? event.clientX : event.clientY;
separator.setPointerCapture(event.pointerId);
const move = (moveEvent) => {
if (moveEvent.pointerId !== event.pointerId) return;
const coordinate = dimension.axis === "x" ? moveEvent.clientX : moveEvent.clientY;
const next = Math.max(
dimension.minimum,
Math.min(dimension.maximum, initial + (coordinate - origin) * dimension.direction),
);
workbench.style.setProperty(dimension.property, `${next}px`);
renderer.updateViewport(scale, offset);
};
const finish = (finishEvent) => {
if (finishEvent.pointerId !== event.pointerId) return;
if (separator.hasPointerCapture(event.pointerId))
separator.releasePointerCapture(event.pointerId);
const value = Number.parseInt(
getComputedStyle(workbench).getPropertyValue(dimension.property), 10,
);
localStorage.setItem(`labfy-layout:${dimension.key}`, String(value));
separator.removeEventListener("pointermove", move);
separator.removeEventListener("pointerup", finish);
separator.removeEventListener("pointercancel", finish);
};
separator.addEventListener("pointermove", move);
separator.addEventListener("pointerup", finish);
separator.addEventListener("pointercancel", finish);
});
}
}
function configureControls() { function configureControls() {
// CONTRACT: la disposition est une préférence locale par origine ; elle ne // CONTRACT: la disposition est une préférence locale par origine ; elle ne
// transporte ni l'enquête, ni un secret, ni un état métier vers le navigateur. // transporte ni l'enquête, ni un secret, ni un état métier vers le navigateur.
@ -1590,6 +1677,26 @@ function configureControls() {
offset = { x: 0, y: 0 }; offset = { x: 0, y: 0 };
render({ focusDomNodeId: selectedNodeId }); render({ focusDomNodeId: selectedNodeId });
}); });
byId("graph-search-focus").addEventListener("click", () => byId("search").focus());
byId("graph-home").addEventListener("click", resetGlobalView);
byId("graph-reset-layout").addEventListener("click", () => byId("reset-layout").click());
byId("graph-fullscreen").addEventListener("click", () => {
const workbench = document.querySelector(".workbench");
const active = workbench.classList.toggle("graph-priority");
byId("graph-fullscreen").setAttribute("aria-pressed", String(active));
byId("graph-fullscreen").title = active ? "Quitter le mode graphe" : "Agrandir le graphe";
requestAnimationFrame(() => renderer.updateViewport(scale, offset));
});
byId("activity-filter").addEventListener("change", (event) => {
for (const entry of document.querySelectorAll("[data-activity-type]"))
entry.hidden = Boolean(event.target.value) &&
entry.dataset.activityType !== event.target.value;
});
byId("activity-expert-toggle").addEventListener("click", (event) => {
const active = byId("inspector").classList.toggle("expert-mode");
event.currentTarget.setAttribute("aria-pressed", String(active));
event.currentTarget.textContent = active ? "Mode normal" : "Mode expert";
});
byId("type-filter").addEventListener("change", (event) => { byId("type-filter").addEventListener("change", (event) => {
filters.type = event.target.value; filters.type = event.target.value;
render(); render();
@ -1629,7 +1736,7 @@ function configureGraphInteractions() {
// coordonnées épinglées qui restent un état de présentation local. // coordonnées épinglées qui restent un état de présentation local.
new ResizeObserver(() => requestAnimationFrame(() => { new ResizeObserver(() => requestAnimationFrame(() => {
const panel = byId("graph-panel"); const panel = byId("graph-panel");
if (!graph_scale_initialized && snapshot.nodes.length > 0 && if (coreMode && !graph_scale_initialized && snapshot.nodes.length > 0 &&
panel.clientWidth > 100 && panel.clientHeight > 100) { panel.clientWidth > 100 && panel.clientHeight > 100) {
// WHY: le snapshot peut arriver pendant que la coque tri-panneaux passe // WHY: le snapshot peut arriver pendant que la coque tri-panneaux passe
// de hidden à sa largeur finale. L'auto-fit unique évite que des nœuds // de hidden à sa largeur finale. L'auto-fit unique évite que des nœuds
@ -1674,9 +1781,10 @@ function configureGraphInteractions() {
event.preventDefault(); event.preventDefault();
scale = Math.max( scale = Math.max(
0.25, 0.25,
Math.min(3, scale * (event.deltaY < 0 ? 1.1 : 0.9)), Math.min(3, scale * (event.deltaY < 0 ? 1.2 : 0.9)),
); );
renderer.updateViewport(scale, offset); renderer.updateViewport(scale, offset);
byId("graph-zoom").textContent = `${Math.round(scale * 100)} %`;
}, },
{ passive: false }, { passive: false },
); );
@ -1719,6 +1827,12 @@ function configureGraphInteractions() {
byId("node-context-menu").hidden = true; byId("node-context-menu").hidden = true;
svg.focus(); svg.focus();
} }
if (
event.key === "Escape" &&
document.querySelector(".workbench").classList.contains("graph-priority")
) {
byId("graph-fullscreen").click();
}
}); });
document.addEventListener("pointerdown", (event) => { document.addEventListener("pointerdown", (event) => {
if ( if (
@ -1834,6 +1948,7 @@ async function activateWorkspace(opened) {
// repose pas sur la largeur héritée lorsque le shell était hidden. // repose pas sur la largeur héritée lorsque le shell était hidden.
await new Promise((resolve) => requestAnimationFrame(resolve)); await new Promise((resolve) => requestAnimationFrame(resolve));
scale = fitGraphScale(); scale = fitGraphScale();
graph_scale_initialized = true;
syncTypeFilterOptions(); syncTypeFilterOptions();
restoreReportDraft(); restoreReportDraft();
render(); render();
@ -1884,6 +1999,7 @@ async function activateLegacyWorkspace(session) {
coreMode ? "Lecture seule — snapshot cœur chargé" : `Connecté · révision ${snapshot.revision}`; coreMode ? "Lecture seule — snapshot cœur chargé" : `Connecté · révision ${snapshot.revision}`;
scale = coreMode ? fitGraphScale() : scale = coreMode ? fitGraphScale() :
Math.min(1, Math.max(0.6, (svg.clientWidth - 160) / 700)); Math.min(1, Math.max(0.6, (svg.clientWidth - 160) / 700));
graph_scale_initialized = true;
syncTypeFilterOptions(); syncTypeFilterOptions();
restoreReportDraft(); restoreReportDraft();
render(); render();
@ -1941,12 +2057,13 @@ async function openWorkspace(workspaceId, button) {
function configureApplication() { function configureApplication() {
configureControls(); configureControls();
configureResizableLayout();
configureGraphInteractions(); configureGraphInteractions();
configureImport(); configureImport();
const drawerCollapsed = localStorage.getItem("labfy-pane:drawer") === "collapsed"; const drawerCollapsed = localStorage.getItem("labfy-pane:drawer") === "collapsed";
if (drawerCollapsed && !byId("work-panel").classList.contains("collapsed")) if (drawerCollapsed && !byId("work-panel").classList.contains("collapsed"))
byId("work-panel-toggle").click(); byId("work-panel-toggle").click();
byId("agent-send").addEventListener("click", () => { const submitAgentPrompt = () => {
const prompt = byId("agent-prompt").value.trim(); const prompt = byId("agent-prompt").value.trim();
if (!prompt) { byId("agent-prompt").focus(); return; } if (!prompt) { byId("agent-prompt").focus(); return; }
const card = document.createElement("li"); const card = document.createElement("li");
@ -1956,6 +2073,13 @@ function configureApplication() {
card.append(label, text); card.append(label, text);
document.querySelector(".agent-conversation").append(card); document.querySelector(".agent-conversation").append(card);
byId("agent-prompt").value = ""; byId("agent-prompt").value = "";
};
byId("agent-send").addEventListener("click", submitAgentPrompt);
byId("agent-prompt").addEventListener("keydown", (event) => {
if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
submitAgentPrompt();
}
}); });
byId("home-button").addEventListener("click", () => void showLibrary({ focus: true })); byId("home-button").addEventListener("click", () => void showLibrary({ focus: true }));
byId("workspace-create-form").addEventListener("submit", async (event) => { byId("workspace-create-form").addEventListener("submit", async (event) => {

View file

@ -56,7 +56,7 @@
placeholder="Rechercher un objet…" placeholder="Rechercher un objet…"
/><button id="search-button">Recentrer</button></label /><button id="search-button">Recentrer</button></label
> >
<div id="connection" role="status">Chargement…</div> <div class="topbar-status"><span id="connection" role="status">Chargement…</span><button id="graph-fullscreen" class="icon-button" type="button" aria-pressed="false" title="Agrandir le graphe">⛶<span class="sr-only">Agrandir le graphe</span></button></div>
</header> </header>
<nav class="projection-bar" aria-label="Projections et contrôles du graphe"> <nav class="projection-bar" aria-label="Projections et contrôles du graphe">
<div class="projection-tabs" role="group"> <div class="projection-tabs" role="group">
@ -93,19 +93,24 @@
<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 IA · préparation</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 IA · préparation</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>Prompt à l’agent</span><textarea id="agent-prompt" maxlength="2000" placeholder="Décrivez l’intention d’enquête…" aria-describedby="agent-prompt-hint"></textarea><small id="agent-prompt-hint">Ctrl+Entrée pour préparer la demande · aucune action n’est lancée.</small><div class="agent-prompt-actions"><button type="button" id="agent-attachment" disabled title="Pièces jointes indisponibles dans cette tranche">Joindre</button><button type="button" id="agent-send">Préparer une demande</button></div></label>
<div class="agent-scroll"> <div class="agent-scroll">
<div class="agent-status"><span aria-hidden="true">◌</span><span>IA locale non connectée</span><small>Surface prête pour un agent futur</small></div>
<p class="agent-mission">Cartographier les relations autour de <strong>example.test</strong> en respectant les autorisations explicites.</p> <p class="agent-mission">Cartographier les relations autour de <strong>example.test</strong> en respectant les autorisations explicites.</p>
<ol class="agent-conversation" aria-label="Conversation SPECIMEN"> <ol class="agent-conversation" aria-label="Conversation SPECIMEN">
<li class="agent-card user"><b>USER</b><p>Montre les pivots passifs disponibles.</p></li> <li class="agent-card user"><b>USER</b><p>Montre les pivots passifs disponibles.</p></li>
<li class="agent-card agent"><b>AGENT</b><p>Je prépare un plan explicable. Aucune action réseau ne part sans capability et autorisation.</p></li>
<li class="agent-card plan"><b>PLAN</b><p>1. Lire les observations · 2. Proposer les capabilities · 3. Attendre l’accord.</p></li> <li class="agent-card plan"><b>PLAN</b><p>1. Lire les observations · 2. Proposer les capabilities · 3. Attendre l’accord.</p></li>
<li class="agent-card hypothesis"><b>HYPOTHESIS</b><p>Le domaine et l’adresse contact peuvent partager une provenance, sans confirmer une identité.</p></li> <li class="agent-card hypothesis"><b>HYPOTHESIS</b><p>Le domaine et l’adresse contact peuvent partager une provenance, sans confirmer une identité.</p></li>
<li class="agent-card authorization"><b>AUTHORIZATION</b><p><strong>rdap.lookup_domain</strong><br/>Cible : example.test · Provider : SPECIMEN loopback<br/>Sortie : nom de domaine · Exposition : passive · Budget : 1 requête<br/><button type="button">Examiner l’autorisation</button></p></li> <li class="agent-card authorization"><b>AUTHORIZATION</b><p><strong>rdap.lookup_domain</strong></p><dl><dt>Cible</dt><dd>example.test</dd><dt>Provider</dt><dd>SPECIMEN loopback</dd><dt>Données sortantes</dt><dd>Nom de domaine</dd><dt>Action</dt><dd>Lookup passif</dd><dt>Exposition</dt><dd>Passive</dd><dt>Budget</dt><dd>1 requête</dd><dt>Raison</dt><dd>Vérifier une provenance déclarée</dd></dl><button type="button">Examiner l’autorisation</button></li>
<li class="agent-card result"><b>RESULT</b><p>7 observations reçues ; aucune relation n’est promue automatiquement.</p></li> <li class="agent-card result"><b>RESULT</b><p>7 observations reçues ; aucune relation n’est promue automatiquement.</p></li>
<li class="agent-card warning"><b>WARNING</b><p>Les hypothèses restent distinctes des observations et des décisions humaines.</p></li>
</ol> </ol>
<label class="agent-prompt"><span>Prompt à l’agent</span><textarea id="agent-prompt" maxlength="2000" placeholder="Décrivez l’intention d’enquête…"></textarea><button type="button" id="agent-send">Préparer une demande</button></label>
</div> </div>
</aside> </aside>
<div id="agent-resizer" class="pane-resizer pane-resizer-vertical" role="separator" aria-label="Redimensionner le panneau Agent" aria-orientation="vertical" tabindex="0"></div>
<section id="graph-panel" aria-label="Graphe interactif"> <section id="graph-panel" aria-label="Graphe interactif">
<div class="graph-toolbar" aria-label="Commandes rapides du graphe"><button id="graph-search-focus" type="button" title="Rechercher dans le graphe">⌕<span class="sr-only">Rechercher</span></button><button id="graph-home" type="button" title="Recentrer le graphe">⌂<span class="sr-only">Recentrer</span></button><button id="graph-reset-layout" type="button" title="Réinitialiser les positions épinglées">↺<span class="sr-only">Réinitialiser les positions</span></button><span id="graph-zoom" aria-live="polite">100 %</span></div>
<div id="graph-state" role="alert"></div> <div id="graph-state" role="alert"></div>
<div class="graph-legend" aria-label="Légende"> <div class="graph-legend" aria-label="Légende">
<span><i class="shape round"></i>Entité</span <span><i class="shape round"></i>Entité</span
@ -166,10 +171,11 @@
</div> </div>
<button class="pane-toggle" data-pane="inspector" aria-expanded="true">Réduire</button> <button class="pane-toggle" data-pane="inspector" aria-expanded="true">Réduire</button>
</div> </div>
<div class="activity-controls"><label>Filtrer <select id="activity-filter"><option value="">Tous les événements</option><option>PLAN</option><option>POLICY</option><option>TOOL</option><option>JOB</option><option>NETWORK</option><option>RESULT</option><option>ERROR</option><option>RESOURCE</option><option>AGENT</option></select></label><button id="activity-expert-toggle" type="button" aria-pressed="false">Mode expert</button></div>
<section class="activity-stream" aria-label="Timeline technique SPECIMEN"> <section class="activity-stream" aria-label="Timeline technique SPECIMEN">
<div class="activity-entry"><time>15:42:12</time><b>TOOL</b><span>rdap.lookup_domain<br/><code>target = example.test</code></span></div> <details class="activity-entry" data-activity-type="TOOL"><summary><time>15:42:12</time><b>TOOL</b><span>rdap.lookup_domain<br/><code>target = example.test</code></span></summary><pre>provider: specimen-loopback\nlatence: 34 ms\nsecret: [non affiché]</pre></details>
<div class="activity-entry policy"><time>15:42:12</time><b>POLICY</b><span>Autorisé · budget restant 18/20</span></div> <details class="activity-entry policy" data-activity-type="POLICY"><summary><time>15:42:12</time><b>POLICY</b><span>Autorisé · budget restant 18/20</span></summary><pre>risk_class: PASSIVE\nauthorization: accord explicite</pre></details>
<div class="activity-entry result"><time>15:42:13</time><b>RESULT</b><span>1 artefact · 7 observations</span></div> <details class="activity-entry result" data-activity-type="RESULT"><summary><time>15:42:13</time><b>RESULT</b><span>1 artefact · 7 observations</span></summary><pre>provenance: tool-result/specimen\nstdout: borné</pre></details>
</section> </section>
<details class="system-card"><summary>Système · ressources réelles</summary><dl><dt>CPU</dt><dd id="system-cpu">Disponible via backend</dd><dt>RAM</dt><dd id="system-ram">Disponible via backend</dd><dt>Jobs actifs</dt><dd id="system-jobs">Lecture JobStore</dd><dt>Instance Web</dt><dd id="system-instance">Loopback</dd><dt>Providers</dt><dd id="system-providers">Selon capabilities</dd></dl><p class="future-slots">Prévu : modèle · quantification · GPU AMD · KV cache · réserve RAM · swap · scratch IA.</p></details> <details class="system-card"><summary>Système · ressources réelles</summary><dl><dt>CPU</dt><dd id="system-cpu">Disponible via backend</dd><dt>RAM</dt><dd id="system-ram">Disponible via backend</dd><dt>Jobs actifs</dt><dd id="system-jobs">Lecture JobStore</dd><dt>Instance Web</dt><dd id="system-instance">Loopback</dd><dt>Providers</dt><dd id="system-providers">Selon capabilities</dd></dl><p class="future-slots">Prévu : modèle · quantification · GPU AMD · KV cache · réserve RAM · swap · scratch IA.</p></details>
<div class="tablist" role="tablist"> <div class="tablist" role="tablist">
@ -211,26 +217,46 @@
</table> </table>
</section> </section>
</aside> </aside>
<div id="inspector-resizer" class="pane-resizer pane-resizer-vertical" role="separator" aria-label="Redimensionner le panneau Activité" aria-orientation="vertical" tabindex="0"></div>
<section id="work-panel" class="work-panel" aria-label="Travail local"> <section id="work-panel" class="work-panel" aria-label="Travail local">
<div class="work-panel-header"> <div class="work-panel-header">
<div class="tablist" role="tablist"> <div class="tablist" role="tablist">
<button <button role="tab" data-work-panel="details-drawer-panel">Détails</button
role="tab" ><button role="tab" data-work-panel="provenance-drawer-panel">Provenance</button
data-work-panel="tasks-panel" ><button role="tab" data-work-panel="timeline-drawer-panel">Timeline</button
aria-selected="true" ><button role="tab" data-work-panel="research-panel">Recherches</button
> ><button role="tab" data-work-panel="observations-drawer-panel">Observations</button
Tâches</button ><button role="tab" data-work-panel="finance-drawer-panel">Finance</button
><button role="tab" data-work-panel="reports-panel">Rapports</button
><button role="tab" data-work-panel="tasks-panel" aria-selected="true">Tâches</button
><button role="tab" data-work-panel="next-panel"> ><button role="tab" data-work-panel="next-panel">
Prochaines actions</button Prochaines actions</button
><button role="tab" data-work-panel="plans-panel">Plans</button ><button role="tab" data-work-panel="plans-panel">Plans</button
><button role="tab" data-work-panel="research-panel">Recherches</button
><button role="tab" data-work-panel="reports-panel">
Rapports
</button>
</div> </div>
<button id="work-panel-toggle" aria-expanded="true">Réduire</button> <button id="work-panel-toggle" aria-expanded="true">Réduire</button>
</div> </div>
<div class="work-panel-body"> <div class="work-panel-body">
<section id="details-drawer-panel" class="work-tab-panel" hidden>
<h3>Détails de la sélection</h3>
<p id="drawer-details-summary">Sélectionnez un objet du graphe pour conserver son contexte ici.</p>
</section>
<section id="provenance-drawer-panel" class="work-tab-panel" hidden>
<h3>Chaîne de provenance</h3>
<p id="drawer-provenance-summary">Aucune source sélectionnée.</p>
<p class="empty-state">Les dérivés et transformations sont affichés sans confondre observation et hypothèse.</p>
</section>
<section id="timeline-drawer-panel" class="work-tab-panel" hidden>
<h3>Timeline</h3>
<p id="drawer-timeline-summary">La chronologie pertinente apparaît avec l’objet sélectionné.</p>
</section>
<section id="observations-drawer-panel" class="work-tab-panel" hidden>
<h3>Observations</h3>
<p id="drawer-observations-summary">Aucune observation à promouvoir automatiquement.</p>
</section>
<section id="finance-drawer-panel" class="work-tab-panel" hidden>
<h3>Finance</h3>
<p class="empty-state">Aucune donnée financière n’est simulée dans cette vue SPECIMEN.</p>
</section>
<section id="tasks-panel" class="work-tab-panel"> <section id="tasks-panel" class="work-tab-panel">
<h3>Jobs locaux</h3> <h3>Jobs locaux</h3>
<p id="jobs-note">Chargement…</p> <p id="jobs-note">Chargement…</p>
@ -331,6 +357,7 @@
</section> </section>
</div> </div>
</section> </section>
<div id="drawer-resizer" class="pane-resizer pane-resizer-horizontal" role="separator" aria-label="Redimensionner le tiroir inférieur" aria-orientation="horizontal" tabindex="0"></div>
</main> </main>
<dialog id="evidence-dialog" aria-labelledby="evidence-title"> <dialog id="evidence-dialog" aria-labelledby="evidence-title">
<form method="dialog" class="dialog-close"><button>Fermer</button></form> <form method="dialog" class="dialog-close"><button>Fermer</button></form>

View file

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

View file

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

View file

@ -1,6 +1,10 @@
:root { :root {
--base: #1e1e2e; --base: #1e1e2e;
--mantle: #181825;
--crust: #11111b;
--panel: #181825; --panel: #181825;
--surface0: #313244;
--surface1: #45475a;
--raised: #313244; --raised: #313244;
--border: #45475a; --border: #45475a;
--accent: #b4befe; --accent: #b4befe;
@ -12,7 +16,7 @@
--yellow: #f9e2af; --yellow: #f9e2af;
--top: 3.6rem; --top: 3.6rem;
--nav: 3.3rem; --nav: 3.3rem;
--drawer: 15rem; --drawer: 240px;
} }
.modal-card { position: fixed; inset: 50% auto auto 50%; transform: translate(-50%,-50%); .modal-card { position: fixed; inset: 50% auto auto 50%; transform: translate(-50%,-50%);
@ -465,6 +469,7 @@ aside h4,
} }
.work-panel.collapsed { .work-panel.collapsed {
grid-template-rows: auto 0; grid-template-rows: auto 0;
overflow: hidden;
} }
.work-panel-header { .work-panel-header {
display: flex; display: flex;
@ -656,7 +661,7 @@ body.login .workbench {
display: none; display: none;
} }
.workbench.drawer-collapsed { .workbench.drawer-collapsed {
grid-template-rows: minmax(0, 1fr) auto; grid-template-rows: minmax(0, 1fr) 2.7rem;
} }
#evidence-dialog { #evidence-dialog {
@ -734,14 +739,39 @@ body.login .workbench {
} }
/* CONTRACT: la coque finale garde le graphe dominant ; les trois surfaces /* CONTRACT: la coque finale garde le graphe dominant ; les trois surfaces
* sont des vues de la même enquête, jamais des stores concurrents. */ * sont des vues de la même enquête, jamais des stores concurrents. */
.workbench { grid-template-columns: minmax(15rem,20rem) minmax(0,1fr) minmax(18rem,23rem); grid-template-areas: "agent graph inspector" "work work work"; } .topbar-status { display: flex; align-items: center; justify-content: end; gap: .45rem; min-width: 0; }
.agent-panel { grid-area: agent; min-width:0; min-height:0; display:grid; grid-template-rows:auto minmax(0,1fr); background:linear-gradient(180deg,#181825,#11111b); border-right:1px solid var(--border); } .icon-button, .graph-toolbar button { display: inline-grid; place-items: center; min-width: 2.1rem; min-height: 2.1rem; padding: .25rem; }
.agent-scroll { min-height:0; overflow:auto; padding:.75rem; }.agent-mission { margin:0 0 .8rem; color:var(--muted); line-height:1.45; }.agent-conversation { display:grid; gap:.55rem; margin:0; padding:0; list-style:none; }.agent-card { border:1px solid var(--border); border-left:3px solid var(--blue); border-radius:.65rem; padding:.6rem .7rem; background:#1e1e2ecc; }.agent-card b { font-size:.68rem; letter-spacing:.08em; color:var(--muted); }.agent-card p { margin:.3rem 0 0; line-height:1.4; }.agent-card.plan { border-left-color:var(--accent); }.agent-card.hypothesis { border-left-color:var(--yellow); }.agent-card.authorization { border-left-color:#cba6f7; }.agent-card.result { border-left-color:var(--green); }.agent-prompt { display:grid; gap:.4rem; margin-top:.8rem; }.agent-prompt textarea { min-height:5.5rem; resize:vertical; }.pane-toggle { padding:.28rem .48rem; font-size:.78rem; } .workbench { position: relative; --agent-pane: 320px; --inspector-pane: 368px; grid-template-columns: minmax(13rem, var(--agent-pane)) minmax(0, 1fr) minmax(16rem, var(--inspector-pane)); grid-template-areas: "agent graph inspector" "work work work"; }
.activity-stream { display:grid; gap:.35rem; margin-bottom:.75rem; padding-bottom:.75rem; border-bottom:1px solid var(--border); }.activity-entry { display:grid; grid-template-columns:4.4rem 4.2rem minmax(0,1fr); gap:.3rem; padding:.42rem; border-radius:.42rem; background:#11111b; font-size:.78rem; }.activity-entry time { color:var(--muted); }.activity-entry b { color:var(--blue); font-size:.7rem; }.activity-entry.policy b { color:var(--yellow); }.activity-entry.result b { color:var(--green); }.activity-entry code { color:var(--muted); overflow-wrap:anywhere; }.system-card { margin-bottom:.75rem; padding:.55rem; border:1px solid var(--border); border-radius:.55rem; background:#11111b; }.system-card summary { color:var(--accent); cursor:pointer; }.system-card dl { display:grid; grid-template-columns:1fr auto; gap:.3rem .6rem; margin:.7rem 0; font-size:.8rem; }.system-card dd { margin:0; color:var(--muted); text-align:right; }.future-slots { margin:0; color:var(--muted); font-size:.75rem; line-height:1.35; } .agent-panel { grid-area: agent; min-width: 0; min-height: 0; display: grid; grid-template-rows: auto auto minmax(0, 1fr); background: linear-gradient(180deg, var(--panel), var(--crust)); border-right: 1px solid var(--border); }
.agent-panel.pane-collapsed { grid-template-rows: auto; }.agent-panel.pane-collapsed .agent-scroll { display:none; }.agent-panel.pane-collapsed { min-width: 5.5rem; }.agent-panel.pane-collapsed h2 { display:none; }#inspector.pane-collapsed > :not(.panel-heading) { display:none; }#inspector.pane-collapsed { min-width:5.5rem; }#inspector.pane-collapsed h2 { display:none; } .panel-heading { display: flex; align-items: start; justify-content: space-between; gap: .5rem; padding: .75rem .75rem 0; }
.agent-scroll { min-height: 0; overflow: auto; padding: .75rem; }
.agent-status { display: grid; grid-template-columns: auto 1fr; gap: .15rem .45rem; align-items: center; padding: .5rem .6rem; margin-bottom: .7rem; border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border)); border-radius: .65rem; color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, var(--crust)); }
.agent-status small { grid-column: 2; color: var(--muted); }
.agent-mission { margin: 0 0 .8rem; color: var(--muted); line-height: 1.45; }
.agent-conversation { display: grid; gap: .55rem; margin: 0; padding: 0; list-style: none; }
.agent-card { border: 1px solid var(--border); border-left: 3px solid var(--blue); border-radius: .65rem; padding: .6rem .7rem; background: color-mix(in srgb, var(--base) 88%, var(--surface0)); box-shadow: 0 .3rem .8rem rgb(0 0 0 / 12%); }
.agent-card b { font-size: .68rem; letter-spacing: .09em; color: var(--muted); }
.agent-card p { margin: .3rem 0 0; line-height: 1.4; }
.agent-card.agent { border-left-color: var(--blue); }.agent-card.plan { border-left-color: var(--accent); }.agent-card.hypothesis { border-left-color: var(--yellow); }.agent-card.authorization { border-left-color: #cba6f7; }.agent-card.result { border-left-color: var(--green); }.agent-card.warning { border-left-color: var(--red); }
.agent-card.authorization dl { display: grid; grid-template-columns: minmax(5.5rem, auto) 1fr; gap: .2rem .5rem; margin: .55rem 0; font-size: .78rem; }.agent-card.authorization dt { color: var(--muted); }.agent-card.authorization dd { margin: 0; overflow-wrap: anywhere; }
.agent-prompt { z-index: 1; display: grid; gap: .4rem; margin: .5rem .75rem .35rem; padding: .55rem; background: var(--mantle); border: 1px solid var(--border); border-radius: .65rem; }.agent-prompt textarea { min-height: 5.5rem; max-height: 9rem; resize: vertical; }.agent-prompt small { color: var(--muted); }.agent-prompt-actions { display: flex; justify-content: space-between; gap: .4rem; }.pane-toggle { padding: .28rem .48rem; font-size: .78rem; }
.graph-toolbar { position: absolute; z-index: 4; left: .65rem; bottom: 2.35rem; display: flex; align-items: center; gap: .3rem; padding: .3rem; border: 1px solid var(--border); border-radius: .65rem; background: rgb(17 17 27 / 88%); box-shadow: 0 .5rem 1.5rem rgb(0 0 0 / 22%); }.graph-toolbar button { background: var(--surface0); }.graph-toolbar span { min-width: 3.6rem; color: var(--muted); font-size: .75rem; text-align: center; }
.activity-controls { display: flex; align-items: center; justify-content: space-between; gap: .45rem; margin: 0 0 .55rem; font-size: .78rem; }.activity-controls label { display: flex; align-items: center; gap: .3rem; min-width: 0; }.activity-controls select { min-width: 0; max-width: 10.6rem; padding: .28rem; }.activity-controls button { padding: .3rem .42rem; white-space: nowrap; }
.activity-stream { display: grid; gap: .35rem; margin-bottom: .75rem; padding-bottom: .75rem; border-bottom: 1px solid var(--border); }.activity-entry { padding: .42rem; border: 1px solid transparent; border-radius: .48rem; background: var(--crust); font-size: .78rem; }.activity-entry summary { display: grid; grid-template-columns: 4.4rem 4.2rem minmax(0, 1fr); gap: .3rem; cursor: pointer; list-style: none; }.activity-entry summary::-webkit-details-marker { display: none; }.activity-entry time { color: var(--muted); }.activity-entry b { color: var(--blue); font-size: .7rem; }.activity-entry.policy b { color: var(--yellow); }.activity-entry.result b { color: var(--green); }.activity-entry code { color: var(--muted); overflow-wrap: anywhere; }.activity-entry pre { display: none; margin: .5rem 0 0; padding: .45rem; overflow: auto; color: var(--muted); background: var(--mantle); border-radius: .35rem; white-space: pre-wrap; }.expert-mode .activity-entry[open] { border-color: var(--surface1); }.expert-mode .activity-entry[open] pre { display: block; }
.system-card { margin-bottom: .75rem; padding: .55rem; border: 1px solid var(--border); border-radius: .55rem; background: var(--crust); }.system-card summary { color: var(--accent); cursor: pointer; }.system-card dl { display: grid; grid-template-columns: 1fr auto; gap: .3rem .6rem; margin: .7rem 0; font-size: .8rem; }.system-card dd { margin: 0; color: var(--muted); text-align: right; }.future-slots { margin: 0; color: var(--muted); font-size: .75rem; line-height: 1.35; }
.agent-panel.pane-collapsed { grid-template-rows: auto; min-width: 5.5rem; }.agent-panel.pane-collapsed .agent-scroll, .agent-panel.pane-collapsed .agent-prompt, #inspector.pane-collapsed > :not(.panel-heading) { display: none; }.agent-panel.pane-collapsed h2, #inspector.pane-collapsed h2 { display: none; }#inspector.pane-collapsed { min-width: 5.5rem; }
.pane-resizer { position: absolute; z-index: 7; border: 0; background: transparent; }.pane-resizer::after { content: ""; position: absolute; background: var(--accent); opacity: 0; transition: opacity .15s; }.pane-resizer:hover::after, .pane-resizer:focus-visible::after { opacity: .78; }.pane-resizer-vertical { top: 0; bottom: var(--drawer); width: .75rem; cursor: col-resize; }.pane-resizer-vertical::after { inset: 18% calc(50% - 1px); width: 2px; border-radius: 2px; }.pane-resizer-horizontal { left: 0; right: 0; bottom: calc(var(--drawer) - .375rem); height: .75rem; cursor: row-resize; }.pane-resizer-horizontal::after { left: 38%; right: 38%; top: calc(50% - 1px); height: 2px; border-radius: 2px; }#agent-resizer { left: calc(var(--agent-pane) - .375rem); }#inspector-resizer { right: calc(var(--inspector-pane) - .375rem); }.workbench.drawer-collapsed .pane-resizer-horizontal { display: none; }.workbench.drawer-collapsed .pane-resizer-vertical { bottom: 2.6rem; }
@media (max-width: 900px) { @media (max-width: 900px) {
.workbench { grid-template-columns:minmax(0,1fr); grid-template-rows:auto minmax(18rem,1fr) auto var(--drawer); grid-template-areas:"agent" "graph" "inspector" "work"; }.agent-panel { max-height:19rem; border-right:0; border-bottom:1px solid var(--border); } .workbench { grid-template-columns:minmax(0,1fr); grid-template-rows:auto minmax(18rem,1fr) auto var(--drawer); grid-template-areas:"agent" "graph" "inspector" "work"; }.agent-panel { max-height:19rem; border-right:0; border-bottom:1px solid var(--border); }
.pane-resizer { display: none; }
.workbench.drawer-collapsed { .workbench.drawer-collapsed {
grid-template-rows: minmax(18rem, 1fr) auto auto; grid-template-rows: auto minmax(18rem, 1fr) auto auto;
} }
} }
@media (max-width: 700px) {
.topbar-status { max-width: 42vw; }.topbar-status #connection { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.activity-controls { align-items: stretch; flex-direction: column; }.activity-controls select { max-width: none; width: 100%; }
.agent-panel { max-height: 17rem; }.graph-toolbar { bottom: .7rem; }.work-panel-header .tablist { overflow-x: auto; }
}
.workbench.graph-priority { grid-template-columns: 0 minmax(0, 1fr) 0; grid-template-rows: minmax(0, 1fr) 0; }.workbench.graph-priority #agent-panel, .workbench.graph-priority #inspector, .workbench.graph-priority #work-panel, .workbench.graph-priority .pane-resizer { display: none; }.workbench.graph-priority #graph-panel { grid-column: 1 / -1; grid-row: 1; }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; } }

View file

@ -0,0 +1,97 @@
import assert from "node:assert/strict";
// CONTRACT: les scénarios Firefox utilisent le parcours produit complet. Ce
// helper ne connaît ni code de bootstrap ni cookie synthétique : le serveur
// établit le cookie HttpOnly lors de GET / et l'application lit le CSRF.
export async function openAutomaticSession(page, origin, label = "session") {
await page.goto(origin, { waitUntil: "domcontentloaded" });
return assertAutomaticSessionReady(page, new URL(origin).origin, label);
}
// CONTRACT: Firefox BiDi peut ne jamais résoudre page.goto() lorsqu'un serveur
// possédé redémarre sur la même origine. Cette primitive conserve la navigation
// réelle (GET / et ses redirections) sans faire de cette promesse BiDi la preuve
// de disponibilité. Le même onglet est indispensable aux brouillons
// sessionStorage du scénario A/B.
export async function resumeAutomaticSessionAfterOwnedRestart(page, origin, label = "restart") {
const expectedOrigin = new URL(origin).origin;
assert.equal(page.url(), "about:blank", `${label}: page détachée attendue`);
// WHY: le document racine peut être mis en cache par Firefox après l'arrêt
// de l'instance possédée. Désactiver ce cache pour cette reprise impose le
// GET / réel qui crée le nouveau cookie HttpOnly via la redirection 303.
await page.setCacheEnabled(false);
await replaceLocation(page, origin);
await waitForResumedDocument(page, expectedOrigin);
try {
return await assertAutomaticSessionReady(page, expectedOrigin, label);
} catch (error) {
if (!new Set(["LABFY_WORKBENCH_UNAVAILABLE", "LABFY_SESSION_UNAVAILABLE"])
.has(error?.code)) throw error;
// INVARIANT: cette seule relance ne s'applique qu'après une navigation
// racine valide et une session API 200 ; elle ne peut pas masquer un 401,
// un CSRF absent, une mauvaise origine ou une page login/bootstrap.
if (error.code === "LABFY_WORKBENCH_UNAVAILABLE") await assertSessionApi(page, label);
await replaceLocation(page, origin);
await waitForResumedDocument(page, expectedOrigin);
return assertAutomaticSessionReady(page, expectedOrigin, label);
}
}
async function replaceLocation(page, origin) {
await page.evaluate((nextOrigin) => {
// INVARIANT: le timer laisse l'évaluation se terminer avant le changement
// de document, qui détruirait autrement son ExecutionContext.
window.setTimeout(() => window.location.replace(nextOrigin), 0);
}, origin);
}
async function waitForResumedDocument(page, expectedOrigin) {
await page.waitForFunction((expectedOrigin) =>
location.origin === expectedOrigin &&
(document.readyState === "interactive" || document.readyState === "complete"),
{}, expectedOrigin);
}
async function assertAutomaticSessionReady(page, origin, label) {
assert.equal(await page.evaluate(() => location.origin), origin,
`${label}: origine de reprise incorrecte`);
try {
await page.waitForFunction(() =>
document.querySelector("#library-home:not([hidden]), #workbench-shell:not([hidden])") !== null,
{ timeout: 15000 });
} catch (error) {
const diagnostic = await page.evaluate(() => ({
href: location.href,
readyState: document.readyState,
connection: document.querySelector("#library-connection")?.textContent ?? "",
state: window.__LABFY_TEST__?.getState?.() ?? null,
body: document.body?.textContent?.slice(0, 500) ?? "",
})).catch((diagnosticError) => ({ evaluationError: String(diagnosticError) }));
const unavailable = new Error(
`${label}: workbench indisponible après reprise ${JSON.stringify(diagnostic)}`,
{ cause: error });
unavailable.code = "LABFY_WORKBENCH_UNAVAILABLE";
throw unavailable;
}
assert.equal(await page.$("#bootstrap-code"), null);
assert.equal(await page.$("#login-form"), null);
assert.doesNotMatch(await page.$eval("body", (body) => body.textContent),
/Code de session éphémère/);
const session = await assertSessionApi(page, label);
return session;
}
async function assertSessionApi(page, label) {
const session = await page.evaluate(async () => {
const response = await fetch("/api/v1/session", { cache: "no-store" });
return { status: response.status, body: await response.json() };
});
if (session.status !== 200) {
const unavailable = new Error(`${label}: session API indisponible (${session.status})`);
unavailable.code = "LABFY_SESSION_UNAVAILABLE";
throw unavailable;
}
assert.equal(typeof session.body.csrf, "string", `${label}: CSRF absent`);
assert.ok(session.body.csrf.length > 0, `${label}: CSRF vide`);
return session.body;
}

View file

@ -10,6 +10,9 @@ import {
// CONTRACT: this ordered list is the complete browser-validation suite. // CONTRACT: this ordered list is the complete browser-validation suite.
export const scenarios = Object.freeze([ export const scenarios = Object.freeze([
"test_browser.mjs", "test_browser.mjs",
// CONTRACT: le point d'entrée utilisateur ouvre directement le workbench,
// établit le cookie local et refuse toute origine de mutation étrangère.
"test_direct_session_browser.mjs",
"test_core_browser.mjs", "test_core_browser.mjs",
"test_eml_browser.mjs", "test_eml_browser.mjs",
"test_local_toolkit_browser.mjs", "test_local_toolkit_browser.mjs",
@ -19,6 +22,9 @@ export const scenarios = Object.freeze([
"test_j9_browser.mjs", "test_j9_browser.mjs",
"test_workbench_browser.mjs", "test_workbench_browser.mjs",
"test_workbench_populated_browser.mjs", "test_workbench_populated_browser.mjs",
// CONTRACT: cette vérification Firefox inspecte les tailles cibles, le
// tiroir, les panneaux persistants et la surface Agent uniquement sur fixture.
"test_visual_polish_browser.mjs",
"test_local_workspace_import_browser.mjs", "test_local_workspace_import_browser.mjs",
// 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.

View file

@ -0,0 +1,89 @@
import assert from "node:assert/strict";
import http from "node:http";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import puppeteer from "puppeteer-core";
import { openAutomaticSession } from "./browser_automatic_session.mjs";
const REPOSITORY = new URL("../../..", import.meta.url);
const PROTOTYPE = new URL("..", import.meta.url);
const FIREFOX = process.env.FIREFOX_PATH ?? "/usr/bin/firefox";
function startLauncher(library, runtime, state) {
const result = spawnSync("./tools/labfy", ["--library", library, "--port", "0"], {
cwd: REPOSITORY,
env: { ...process.env, XDG_RUNTIME_DIR: runtime, XDG_STATE_HOME: state },
encoding: "utf8",
});
assert.equal(result.status, 0, result.stderr);
const match = result.stdout.match(/Labfy Web : (http:\/\/127\.0\.0\.1:\d+)\//);
assert.ok(match, `origine du lanceur absente: ${result.stdout}`);
return match[1];
}
function stopLauncher(runtime, state) {
const result = spawnSync("python3", ["web_app.py", "stop"], {
cwd: PROTOTYPE,
env: { ...process.env, XDG_RUNTIME_DIR: runtime, XDG_STATE_HOME: state },
encoding: "utf8",
});
assert.equal(result.status, 0, result.stderr);
}
function requestEvilOrigin(origin, cookie, csrf) {
const body = JSON.stringify({ title: "Refusé SPECIMEN",
idempotency_key: "87000000-0000-4000-8000-000000000002" });
return new Promise((resolve, reject) => {
const target = new URL("/api/v1/library/workspaces", origin);
const request = http.request(target, { method: "POST", headers: {
Cookie: `${cookie.name}=${cookie.value}`, Origin: "http://evil.example",
"Content-Type": "application/json", "Content-Length": Buffer.byteLength(body),
"X-Labfy-CSRF": csrf } }, (response) => {
response.resume();
response.once("end", () => resolve(response.statusCode));
});
request.once("error", reject);
request.end(body);
});
}
const library = await mkdtemp(join(tmpdir(), "labfy-direct-library-SPECIMEN-"));
const profile = await mkdtemp(join(tmpdir(), "labfy-direct-firefox-SPECIMEN-"));
const runtime = await mkdtemp(join(tmpdir(), "labfy-direct-runtime-SPECIMEN-"));
const state = await mkdtemp(join(tmpdir(), "labfy-direct-state-SPECIMEN-"));
let browser;
let started = false;
try {
const origin = startLauncher(library, runtime, state);
started = true;
browser = await puppeteer.launch({ browser: "firefox", executablePath: FIREFOX,
headless: true, userDataDir: profile, args: ["--no-remote"] });
const page = await browser.newPage();
const session = await openAutomaticSession(page, origin);
assert.equal(await page.$("#login-form"), null);
assert.equal(await page.$("#bootstrap-code"), null);
assert.equal((await page.goto(`${origin}/login.html`)).status(), 404);
assert.equal((await page.goto(`${origin}/login.js`)).status(), 404);
await page.goto(origin, { waitUntil: "domcontentloaded" });
const valid = await page.evaluate(async ({ csrf }) => {
const valid = await fetch("/api/v1/library/workspaces", {
method: "POST", headers: { "Content-Type": "application/json",
"X-Labfy-CSRF": csrf },
body: JSON.stringify({ title: "Direct SPECIMEN",
idempotency_key: "87000000-0000-4000-8000-000000000001" }),
});
return valid.status;
}, session);
assert.equal(valid, 201);
const cookie = (await page.cookies()).find((item) => item.name.startsWith("labfy_session_"));
assert.ok(cookie);
assert.equal(await requestEvilOrigin(origin, cookie, session.csrf), 403);
console.log("PASS session directe Firefox : lanceur, workbench, CSRF, Origin et arrêt");
} finally {
if (browser) await browser.close();
if (started) stopLauncher(runtime, state);
await Promise.all([library, profile, runtime, state].map((path) =>
rm(path, { recursive: true, force: true })));
}

View file

@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { spawn, spawnSync } from "node:child_process"; import { spawn, spawnSync } from "node:child_process";
import puppeteer from "puppeteer-core"; import puppeteer from "puppeteer-core";
import { openAutomaticSession } from "./browser_automatic_session.mjs";
const REPOSITORY = new URL("../../..", import.meta.url), const REPOSITORY = new URL("../../..", import.meta.url),
PROTOTYPE = new URL("..", import.meta.url); PROTOTYPE = new URL("..", import.meta.url);
const FIREFOX_PATH = process.env.FIREFOX_PATH ?? "/usr/bin/firefox"; const FIREFOX_PATH = process.env.FIREFOX_PATH ?? "/usr/bin/firefox";
@ -16,11 +17,10 @@ function ready(server) {
); );
server.stdout.on("data", (chunk) => { server.stdout.on("data", (chunk) => {
text += chunk; text += chunk;
const u = text.match(/http:\/\/127\.0\.0\.1:(\d+)\//), const u = text.match(/http:\/\/127\.0\.0\.1:(\d+)\//);
c = text.match(/Code de session éphémère : ([^\s]+)/); if (u) {
if (u && c) {
clearTimeout(timer); clearTimeout(timer);
resolve({ url: u[0], code: c[1] }); resolve({ url: u[0] });
} }
}); });
server.once("exit", (code) => server.once("exit", (code) =>
@ -51,14 +51,6 @@ async function stop(server) {
server.kill("SIGINT"); server.kill("SIGINT");
await new Promise((resolve) => server.once("exit", resolve)); await new Promise((resolve) => server.once("exit", resolve));
} }
async function login(page, session) {
await page.goto(session.url, { waitUntil: "domcontentloaded" });
await page.type("#bootstrap-code", session.code);
await Promise.all([
page.waitForNavigation({ waitUntil: "domcontentloaded" }),
page.click("#login-form button"),
]);
}
const workspace = await mkdtemp(join(tmpdir(), "labfy-j8-browser-")), const workspace = await mkdtemp(join(tmpdir(), "labfy-j8-browser-")),
profile = await mkdtemp(join(tmpdir(), "labfy-j8-firefox-")); profile = await mkdtemp(join(tmpdir(), "labfy-j8-firefox-"));
let browser, server; let browser, server;
@ -97,7 +89,7 @@ try {
if (message.type() === "error") errors.push(message.text()); if (message.type() === "error") errors.push(message.text());
}); });
await page.setViewport({ width: 1440, height: 900 }); await page.setViewport({ width: 1440, height: 900 });
await login(page, session); await openAutomaticSession(page, session.url);
await page.waitForFunction( await page.waitForFunction(
() => () =>
document.querySelectorAll("#planner-list input:not(:disabled)").length >= document.querySelectorAll("#planner-list input:not(:disabled)").length >=
@ -182,7 +174,7 @@ try {
server = null; server = null;
server = start(workspace); server = start(workspace);
const restarted = await ready(server); const restarted = await ready(server);
await login(page, restarted); await openAutomaticSession(page, restarted.url);
await page.waitForFunction( await page.waitForFunction(
() => () =>
document document

View file

@ -4,18 +4,18 @@ import {tmpdir} from "node:os";
import {join} from "node:path"; import {join} from "node:path";
import {spawn, spawnSync} from "node:child_process"; import {spawn, spawnSync} from "node:child_process";
import puppeteer from "puppeteer-core"; import puppeteer from "puppeteer-core";
import { openAutomaticSession } from "./browser_automatic_session.mjs";
const REPOSITORY=new URL("../../..",import.meta.url),PROTOTYPE=new URL("..",import.meta.url); const REPOSITORY=new URL("../../..",import.meta.url),PROTOTYPE=new URL("..",import.meta.url);
const FIREFOX_PATH=process.env.FIREFOX_PATH??"/usr/bin/firefox"; const FIREFOX_PATH=process.env.FIREFOX_PATH??"/usr/bin/firefox";
const delay=ms=>new Promise(resolve=>setTimeout(resolve,ms)); const delay=ms=>new Promise(resolve=>setTimeout(resolve,ms));
function ready(server){return new Promise((resolve,reject)=>{let output="";const timer=setTimeout(()=>reject(new Error("démarrage runtime J8 expiré")),8000);server.stdout.on("data",chunk=>{output+=chunk;const url=output.match(/http:\/\/127\.0\.0\.1:(\d+)\//),code=output.match(/Code de session éphémère : ([^\s]+)/);if(url&&code){clearTimeout(timer);resolve({url:url[0],code:code[1]});}});});} function ready(server){return new Promise((resolve,reject)=>{let output="";const timer=setTimeout(()=>reject(new Error("démarrage runtime J8 expiré")),8000);server.stdout.on("data",chunk=>{output+=chunk;const url=output.match(/http:\/\/127\.0\.0\.1:(\d+)\//);if(url){clearTimeout(timer);resolve({url:url[0]});}});});}
async function waitFile(path){for(let i=0;i<200;i++){try{await access(path);return;}catch{}await delay(10);}throw new Error(`barrière absente: ${path}`);} async function waitFile(path){for(let i=0;i<200;i++){try{await access(path);return;}catch{}await delay(10);}throw new Error(`barrière absente: ${path}`);}
async function login(page,session){await page.goto(session.url,{waitUntil:"domcontentloaded"});await page.type("#bootstrap-code",session.code);await Promise.all([page.waitForNavigation({waitUntil:"domcontentloaded"}),page.click("#login-form button")]);}
async function post(page,path,body){return page.evaluate(async({path,body})=>{const session=await fetch("/api/v1/session").then(r=>r.json());const response=await fetch(path,{method:"POST",headers:{"Content-Type":"application/json","X-Labfy-CSRF":session.csrf},body:JSON.stringify(body)});return {status:response.status,body:await response.json()};},{path,body});} async function post(page,path,body){return page.evaluate(async({path,body})=>{const session=await fetch("/api/v1/session").then(r=>r.json());const response=await fetch(path,{method:"POST",headers:{"Content-Type":"application/json","X-Labfy-CSRF":session.csrf},body:JSON.stringify(body)});return {status:response.status,body:await response.json()};},{path,body});}
async function scenario(kind){const workspace=await mkdtemp(join(tmpdir(),`labfy-j8-${kind}-`)),profile=await mkdtemp(join(tmpdir(),`labfy-j8-${kind}-firefox-`));let browser,server;try{ async function scenario(kind){const workspace=await mkdtemp(join(tmpdir(),`labfy-j8-${kind}-`)),profile=await mkdtemp(join(tmpdir(),`labfy-j8-${kind}-firefox-`));let browser,server;try{
assert.equal(spawnSync("./tools/local-jobs",["init-j7-specimen","--workspace",workspace],{cwd:REPOSITORY,encoding:"utf8"}).status,0);spawnSync("./tools/local-jobs",["export","--workspace",workspace],{cwd:REPOSITORY}); assert.equal(spawnSync("./tools/local-jobs",["init-j7-specimen","--workspace",workspace],{cwd:REPOSITORY,encoding:"utf8"}).status,0);spawnSync("./tools/local-jobs",["export","--workspace",workspace],{cwd:REPOSITORY});
const maximum=kind==="limit"?"100":"5000";server=spawn("python3",["workspace_server.py","--workspace",workspace,"--bridge","../../tools/local-jobs-test","--port","0"],{cwd:PROTOTYPE,env:{...process.env,PYTHONUNBUFFERED:"1",LABFY_TEST_PLAN_ACTIVE_MS:maximum,LABFY_TEST_TOOL_DELAY_MS:"2000"},stdio:["ignore","pipe","pipe"]});const session=await ready(server); const maximum=kind==="limit"?"100":"5000";server=spawn("python3",["workspace_server.py","--workspace",workspace,"--bridge","../../tools/local-jobs-test","--port","0"],{cwd:PROTOTYPE,env:{...process.env,PYTHONUNBUFFERED:"1",LABFY_TEST_PLAN_ACTIVE_MS:maximum,LABFY_TEST_TOOL_DELAY_MS:"2000"},stdio:["ignore","pipe","pipe"]});const session=await ready(server);
browser=await puppeteer.launch({browser:"firefox",executablePath:FIREFOX_PATH,headless:true,userDataDir:profile,args:["--no-remote"]});const page=await browser.newPage();await login(page,session);const planner=JSON.parse(await readFile(join(workspace,"planner-snapshot.json"))),ids=planner.recommendations.filter(x=>x.kind==="ANALYSIS"&&x.available).slice(0,kind==="limit"?2:1).map(x=>x.id);const response=await post(page,"/api/v1/plans",{recommendation_ids:ids,input_revision:planner.input_revision,profile_id:"SPECIMEN_SMALL",idempotency_key:kind==="limit"?"89100000-0000-4000-8000-000000000001":"89200000-0000-4000-8000-000000000001"});assert.equal(response.status,202);const marker=join(workspace,".labfy/runtime/test-tool-started");await waitFile(marker); browser=await puppeteer.launch({browser:"firefox",executablePath:FIREFOX_PATH,headless:true,userDataDir:profile,args:["--no-remote"]});const page=await browser.newPage();await openAutomaticSession(page,session.url);const planner=JSON.parse(await readFile(join(workspace,"planner-snapshot.json"))),ids=planner.recommendations.filter(x=>x.kind==="ANALYSIS"&&x.available).slice(0,kind==="limit"?2:1).map(x=>x.id);const response=await post(page,"/api/v1/plans",{recommendation_ids:ids,input_revision:planner.input_revision,profile_id:"SPECIMEN_SMALL",idempotency_key:kind==="limit"?"89100000-0000-4000-8000-000000000001":"89200000-0000-4000-8000-000000000001"});assert.equal(response.status,202);const marker=join(workspace,".labfy/runtime/test-tool-started");await waitFile(marker);
if(kind==="cancel"){const jobId=spawnSync("sqlite3",[join(workspace,".labfy/runtime/jobs.sqlite"),"SELECT job_id FROM jobs WHERE state='RUNNING';"],{encoding:"utf8"}).stdout.trim();assert.ok(jobId);const pid=Number((await readFile(marker,"utf8")).trim());assert.equal((await post(page,`/api/v1/jobs/${jobId}/cancel`,{})).status,200);await page.waitForFunction(()=>[...document.querySelectorAll("#jobs-list li")].some(x=>x.dataset.jobState==="CANCELLED"),{timeout:10000});assert.throws(()=>process.kill(pid,0));} if(kind==="cancel"){const jobId=spawnSync("sqlite3",[join(workspace,".labfy/runtime/jobs.sqlite"),"SELECT job_id FROM jobs WHERE state='RUNNING';"],{encoding:"utf8"}).stdout.trim();assert.ok(jobId);const pid=Number((await readFile(marker,"utf8")).trim());assert.equal((await post(page,`/api/v1/jobs/${jobId}/cancel`,{})).status,200);await page.waitForFunction(()=>[...document.querySelectorAll("#jobs-list li")].some(x=>x.dataset.jobState==="CANCELLED"),{timeout:10000});assert.throws(()=>process.kill(pid,0));}
else {await page.waitForFunction(()=>document.querySelector("#plans-list")?.textContent.includes("LIMIT_REACHED"),{timeout:10000});const snapshot=JSON.parse(await readFile(join(workspace,"jobs-snapshot.json")));assert.equal(snapshot.jobs.filter(x=>x.state==="BLOCKED").length,1);assert.equal(snapshot.jobs.filter(x=>x.state==="QUEUED").length,1);assert.equal(snapshot.plans[0].remaining_active_ms,0);} else {await page.waitForFunction(()=>document.querySelector("#plans-list")?.textContent.includes("LIMIT_REACHED"),{timeout:10000});const snapshot=JSON.parse(await readFile(join(workspace,"jobs-snapshot.json")));assert.equal(snapshot.jobs.filter(x=>x.state==="BLOCKED").length,1);assert.equal(snapshot.jobs.filter(x=>x.state==="QUEUED").length,1);assert.equal(snapshot.plans[0].remaining_active_ms,0);}
}finally{if(browser)await browser.close();if(server){server.kill("SIGINT");await new Promise(resolve=>server.once("exit",resolve));}await rm(profile,{recursive:true,force:true});await rm(workspace,{recursive:true,force:true});}} }finally{if(browser)await browser.close();if(server){server.kill("SIGINT");await new Promise(resolve=>server.once("exit",resolve));}await rm(profile,{recursive:true,force:true});await rm(workspace,{recursive:true,force:true});}}

View file

@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { spawn, spawnSync } from "node:child_process"; import { spawn, spawnSync } from "node:child_process";
import puppeteer from "puppeteer-core"; import puppeteer from "puppeteer-core";
import { openAutomaticSession } from "./browser_automatic_session.mjs";
const REPOSITORY = new URL("../../..", import.meta.url), const REPOSITORY = new URL("../../..", import.meta.url),
PROTOTYPE = new URL("..", import.meta.url), PROTOTYPE = new URL("..", import.meta.url),
FIREFOX = process.env.FIREFOX_PATH ?? "/usr/bin/firefox"; FIREFOX = process.env.FIREFOX_PATH ?? "/usr/bin/firefox";
@ -17,11 +18,10 @@ function ready(server) {
); );
server.stdout.on("data", (chunk) => { server.stdout.on("data", (chunk) => {
text += chunk; text += chunk;
const u = text.match(/http:\/\/127\.0\.0\.1:(\d+)\//), const u = text.match(/http:\/\/127\.0\.0\.1:(\d+)\//);
c = text.match(/Code de session éphémère : ([^\s]+)/); if (u) {
if (u && c) {
clearTimeout(timer); clearTimeout(timer);
resolve({ url: u[0], code: c[1] }); resolve({ url: u[0] });
} }
}); });
server.once("exit", (code) => server.once("exit", (code) =>
@ -52,14 +52,6 @@ async function stop(server) {
server.kill("SIGINT"); server.kill("SIGINT");
await new Promise((resolve) => server.once("exit", resolve)); await new Promise((resolve) => server.once("exit", resolve));
} }
async function login(page, session) {
await page.goto(session.url, { waitUntil: "domcontentloaded" });
await page.type("#bootstrap-code", session.code);
await Promise.all([
page.waitForNavigation({ waitUntil: "domcontentloaded" }),
page.click("#login-form button"),
]);
}
const workspace = await mkdtemp(join(tmpdir(), "labfy-j9-browser-")), const workspace = await mkdtemp(join(tmpdir(), "labfy-j9-browser-")),
profile = await mkdtemp(join(tmpdir(), "labfy-j9-firefox-")); profile = await mkdtemp(join(tmpdir(), "labfy-j9-firefox-"));
let browser, server; let browser, server;
@ -96,7 +88,7 @@ try {
const errors = []; const errors = [];
page.on("pageerror", (e) => errors.push(String(e))); page.on("pageerror", (e) => errors.push(String(e)));
await page.setViewport({ width: 1440, height: 900 }); await page.setViewport({ width: 1440, height: 900 });
await login(page, session); await openAutomaticSession(page, session.url);
await page.waitForSelector("#view-evidence"); await page.waitForSelector("#view-evidence");
await page.click("#view-evidence"); await page.click("#view-evidence");
await page.click("#view-timeline"); await page.click("#view-timeline");

View file

@ -29,8 +29,7 @@ class LocalWorkspaceImportTest(unittest.TestCase):
self.workspace = Path(self.temporary.name) / "workspace" self.workspace = Path(self.temporary.name) / "workspace"
self.workspace.mkdir(mode=0o700) self.workspace.mkdir(mode=0o700)
self.server = WorkspaceServer(("127.0.0.1", 0), Handler, self.server = WorkspaceServer(("127.0.0.1", 0), Handler,
workspace=self.workspace, bridge=REPOSITORY / "tools" / "local-jobs", workspace=self.workspace, bridge=REPOSITORY / "tools" / "local-jobs")
bootstrap="SPECIMEN-bootstrap")
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start() self.thread.start()
self.cookie = None self.cookie = None
@ -55,9 +54,8 @@ class LocalWorkspaceImportTest(unittest.TestCase):
return response.status, json.loads(data) if data else None return response.status, json.loads(data) if data else None
def login_create(self): def login_create(self):
status, _ = self.request("POST", "/api/v1/session", status, _ = self.request("GET", "/", csrf=False)
{"bootstrap_code":"SPECIMEN-bootstrap"}, csrf=False) self.assertEqual(status, 303)
self.assertEqual(status, 200)
status, value = self.request("POST", "/api/v1/workspace", status, value = self.request("POST", "/api/v1/workspace",
{"title":"Dossier synthétique Ω"}) {"title":"Dossier synthétique Ω"})
self.assertEqual(status, 201); return value self.assertEqual(status, 201); return value
@ -67,20 +65,19 @@ class LocalWorkspaceImportTest(unittest.TestCase):
"init-specimen", "--workspace", str(self.workspace)], "init-specimen", "--workspace", str(self.workspace)],
cwd=REPOSITORY, text=True, capture_output=True, check=False) cwd=REPOSITORY, text=True, capture_output=True, check=False)
self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.returncode, 0, result.stderr)
status, _ = self.request("POST", "/api/v1/session", status, _ = self.request("GET", "/", csrf=False)
{"bootstrap_code":"SPECIMEN-bootstrap"}, csrf=False) self.assertEqual(status, 303)
self.assertEqual(status, 200)
return json.loads((self.workspace / ".labfy/runtime/specimen.json").read_text()) return json.loads((self.workspace / ".labfy/runtime/specimen.json").read_text())
def restart_server(self): def restart_server(self):
self.server.shutdown(); self.server.server_close(); self.thread.join(3) self.server.shutdown(); self.server.server_close(); self.thread.join(3)
self.server = WorkspaceServer(("127.0.0.1", 0), Handler, self.server = WorkspaceServer(("127.0.0.1", 0), Handler,
workspace=self.workspace, bridge=REPOSITORY / "tools" / "local-jobs", workspace=self.workspace, bridge=REPOSITORY / "tools" / "local-jobs")
bootstrap="SPECIMEN-bootstrap")
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start(); self.cookie = None self.thread.start(); self.cookie = None
status, _ = self.request("POST", "/api/v1/session", status, _ = self.request("GET", "/", csrf=False)
{"bootstrap_code":"SPECIMEN-bootstrap"}, csrf=False) self.assertEqual(status, 303)
status, _ = self.request("GET", "/api/v1/session", csrf=False)
self.assertEqual(status, 200) self.assertEqual(status, 200)
def prepare(self, name, data, declared="application/octet-stream"): def prepare(self, name, data, declared="application/octet-stream"):

View file

@ -5,10 +5,10 @@ import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { spawn, spawnSync } from "node:child_process"; import { spawn, spawnSync } from "node:child_process";
import puppeteer from "puppeteer-core"; import puppeteer from "puppeteer-core";
import { openAutomaticSession } from "./browser_automatic_session.mjs";
const PROTOTYPE=new URL("..",import.meta.url),FIREFOX=process.env.FIREFOX_PATH??"/usr/bin/firefox"; const PROTOTYPE=new URL("..",import.meta.url),FIREFOX=process.env.FIREFOX_PATH??"/usr/bin/firefox";
function ready(server){return new Promise((resolve,reject)=>{let out="";const timer=setTimeout(()=>reject(new Error("démarrage import expiré")),10000);server.stdout.on("data",chunk=>{out+=chunk;const u=out.match(/http:\/\/127\.0\.0\.1:\d+\//),c=out.match(/Code de session éphémère : ([^\s]+)/);if(u&&c){clearTimeout(timer);resolve({url:u[0],code:c[1]});}});server.once("exit",code=>reject(new Error(`serveur import arrêté (${code})`)));});} function ready(server){return new Promise((resolve,reject)=>{let out="";const timer=setTimeout(()=>reject(new Error("démarrage import expiré")),10000);server.stdout.on("data",chunk=>{out+=chunk;const u=out.match(/http:\/\/127\.0\.0\.1:\d+\//);if(u){clearTimeout(timer);resolve({url:u[0]});}});server.once("exit",code=>reject(new Error(`serveur import arrêté (${code})`)));});}
async function login(page,session){await page.goto(session.url,{waitUntil:"domcontentloaded"});await page.type("#bootstrap-code",session.code);await Promise.all([page.waitForNavigation({waitUntil:"domcontentloaded"}),page.click("#login-form button")]);}
async function waitForDownload(path){let size=-1,stable=0;for(let attempt=0;attempt<120&&stable<3;attempt++){await new Promise(resolve=>setTimeout(resolve,50));try{const current=(await stat(path)).size;stable=current>0&&current===size?stable+1:0;size=current;}catch{stable=0;}}assert.ok(stable>=3,`téléchargement Firefox terminé ${path}`);return size;} async function waitForDownload(path){let size=-1,stable=0;for(let attempt=0;attempt<120&&stable<3;attempt++){await new Promise(resolve=>setTimeout(resolve,50));try{const current=(await stat(path)).size;stable=current>0&&current===size?stable+1:0;size=current;}catch{stable=0;}}assert.ok(stable>=3,`téléchargement Firefox terminé ${path}`);return size;}
const root=await mkdtemp(join(tmpdir(),"labfy-local-import-browser-")),workspace=join(root,"workspace"),fixtures=join(root,"SPECIMEN_INPUT"),profile=join(root,"firefox"),downloads=join(root,"downloads"); const root=await mkdtemp(join(tmpdir(),"labfy-local-import-browser-")),workspace=join(root,"workspace"),fixtures=join(root,"SPECIMEN_INPUT"),profile=join(root,"firefox"),downloads=join(root,"downloads");
@ -23,7 +23,7 @@ let server,browser;
try{ try{
server=spawn("python3",["workspace_server.py","--workspace",workspace,"--bridge","../../tools/local-jobs","--port","0"],{cwd:PROTOTYPE,env:{...process.env,PYTHONUNBUFFERED:"1"},stdio:["ignore","pipe","pipe"]}); server=spawn("python3",["workspace_server.py","--workspace",workspace,"--bridge","../../tools/local-jobs","--port","0"],{cwd:PROTOTYPE,env:{...process.env,PYTHONUNBUFFERED:"1"},stdio:["ignore","pipe","pipe"]});
const session=await ready(server);browser=await puppeteer.launch({browser:"firefox",executablePath:FIREFOX,headless:true,userDataDir:profile,args:["--no-remote"],extraPrefsFirefox:{"browser.download.folderList":2,"browser.download.dir":downloads,"browser.download.useDownloadDir":true,"browser.helperApps.neverAsk.saveToDisk":"text/html,application/json,application/pdf,text/plain"}}); const session=await ready(server);browser=await puppeteer.launch({browser:"firefox",executablePath:FIREFOX,headless:true,userDataDir:profile,args:["--no-remote"],extraPrefsFirefox:{"browser.download.folderList":2,"browser.download.dir":downloads,"browser.download.useDownloadDir":true,"browser.helperApps.neverAsk.saveToDisk":"text/html,application/json,application/pdf,text/plain"}});
const page=await browser.newPage(),errors=[];page.on("pageerror",e=>errors.push(String(e)));await page.setViewport({width:1440,height:900});await login(page,session); const page=await browser.newPage(),errors=[];page.on("pageerror",e=>errors.push(String(e)));await page.setViewport({width:1440,height:900});await openAutomaticSession(page,session.url);
await page.waitForSelector("#workspace-create:not([hidden])");await page.type("#workspace-title","Enquête Firefox locale Ω");await page.click("#workspace-create-form button");await page.waitForSelector("#workspace-create[hidden]"); await page.waitForSelector("#workspace-create:not([hidden])");await page.type("#workspace-title","Enquête Firefox locale Ω");await page.click("#workspace-create-form button");await page.waitForSelector("#workspace-create[hidden]");
await page.click("#open-import");const input=await page.$("#import-files");await input.uploadFile(eml,eml2,copy,png,jpg); await page.click("#open-import");const input=await page.$("#import-files");await input.uploadFile(eml,eml2,copy,png,jpg);
await page.waitForFunction(()=>document.querySelectorAll("#import-list li").length===5&&[...document.querySelectorAll("#import-list li")].every(x=>x.textContent.includes("préparé")),{timeout:20000}); await page.waitForFunction(()=>document.querySelectorAll("#import-list li").length===5&&[...document.querySelectorAll("#import-list li")].every(x=>x.textContent.includes("préparé")),{timeout:20000});

View file

@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { spawn, spawnSync } from "node:child_process"; import { spawn, spawnSync } from "node:child_process";
import puppeteer from "puppeteer-core"; import puppeteer from "puppeteer-core";
import { openAutomaticSession } from "./browser_automatic_session.mjs";
const REPOSITORY = new URL("../../..", import.meta.url); const REPOSITORY = new URL("../../..", import.meta.url);
const PROTOTYPE = new URL("..", import.meta.url); const PROTOTYPE = new URL("..", import.meta.url);
@ -17,10 +18,9 @@ function ready(server) {
server.stdout.on("data", (chunk) => { server.stdout.on("data", (chunk) => {
text += chunk; text += chunk;
const url = text.match(/http:\/\/127\.0\.0\.1:(\d+)\//); const url = text.match(/http:\/\/127\.0\.0\.1:(\d+)\//);
const code = text.match(/Code de session éphémère : ([^\s]+)/); if (url) {
if (url && code) {
clearTimeout(timer); clearTimeout(timer);
resolve({ url: url[0], code: code[1] }); resolve({ url: url[0] });
} }
}); });
server.once("exit", (code) => reject(new Error(`serveur arrêté (${code})`))); server.once("exit", (code) => reject(new Error(`serveur arrêté (${code})`)));
@ -42,12 +42,6 @@ async function stop(server) {
await new Promise((resolve) => server.once("exit", resolve)); await new Promise((resolve) => server.once("exit", resolve));
} }
async function login(page, session) {
await page.goto(session.url, { waitUntil: "domcontentloaded" });
await page.type("#bootstrap-code", session.code);
await Promise.all([page.waitForNavigation({ waitUntil: "domcontentloaded" }),
page.click("#login-form button")]);
}
function init(workspace) { function init(workspace) {
const result = spawnSync("./tools/local-jobs", const result = spawnSync("./tools/local-jobs",
@ -81,7 +75,7 @@ try {
headless: true, userDataDir: profile, args: ["--no-remote"] }); headless: true, userDataDir: profile, args: ["--no-remote"] });
const page = await browser.newPage(); const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 900 }); await page.setViewport({ width: 1280, height: 900 });
await login(page, sessionA); await openAutomaticSession(page, sessionA.url);
await page.waitForSelector("#object-list button"); await page.waitForSelector("#object-list button");
await page.click("#object-list button"); await page.click("#object-list button");
await page.click('[data-work-panel="research-panel"]'); await page.click('[data-work-panel="research-panel"]');
@ -108,7 +102,7 @@ try {
await stop(server); server = start(workspaceB, authority); await stop(server); server = start(workspaceB, authority);
const sessionB = await ready(server); const pageB = await browser.newPage(); const sessionB = await ready(server); const pageB = await browser.newPage();
await login(pageB, sessionB); await openAutomaticSession(pageB, sessionB.url);
await pageB.click('[data-work-panel="research-panel"]'); await pageB.click('[data-work-panel="research-panel"]');
assert.equal(await pageB.$$eval(".research-card", (nodes) => nodes.length), 0); assert.equal(await pageB.$$eval(".research-card", (nodes) => nodes.length), 0);
assert.match(await pageB.$eval("#research-note", (node) => node.textContent), assert.match(await pageB.$eval("#research-note", (node) => node.textContent),

View file

@ -64,7 +64,7 @@ class ResearchWebTest(unittest.TestCase):
subprocess.run([str(cls.bridge), "export", "--workspace", str(workspace)], subprocess.run([str(cls.bridge), "export", "--workspace", str(workspace)],
cwd=cls.repository, check=True, timeout=15) cwd=cls.repository, check=True, timeout=15)
server = WorkspaceServer(("127.0.0.1", 0), Handler, server = WorkspaceServer(("127.0.0.1", 0), Handler,
workspace=workspace, bridge=cls.bridge, bootstrap="SPECIMEN-RESEARCH", workspace=workspace, bridge=cls.bridge,
research_fixture_authority=f"127.0.0.1:{cls.provider.server_port}") research_fixture_authority=f"127.0.0.1:{cls.provider.server_port}")
thread = threading.Thread(target=server.serve_forever, daemon=True) thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start(); cls.instances.append((server, thread)) thread.start(); cls.instances.append((server, thread))
@ -87,10 +87,9 @@ class ResearchWebTest(unittest.TestCase):
result = response.status, dict(response.getheaders()), data result = response.status, dict(response.getheaders()), data
connection.close(); return result connection.close(); return result
def login(self, server): def open_automatic_session(self, server):
status, headers, _ = self.request(server, "POST", "/api/v1/session", status, headers, _ = self.request(server, "GET", "/")
{"bootstrap_code": "SPECIMEN-RESEARCH"}, origin=server.origin) self.assertEqual(status, 303)
self.assertEqual(status, 200)
cookie = headers["Set-Cookie"].split(";", 1)[0] cookie = headers["Set-Cookie"].split(";", 1)[0]
status, _, body = self.request(server, "GET", "/api/v1/session", status, _, body = self.request(server, "GET", "/api/v1/session",
cookie=cookie) cookie=cookie)
@ -105,7 +104,7 @@ class ResearchWebTest(unittest.TestCase):
def test_two_waves_refusal_never_contacts_and_workspace_b_isolated(self): def test_two_waves_refusal_never_contacts_and_workspace_b_isolated(self):
Provider.requests.clear() Provider.requests.clear()
workspace_a, server_a = self.start_workspace("A") workspace_a, server_a = self.start_workspace("A")
cookie_a, csrf_a = self.login(server_a) cookie_a, csrf_a = self.open_automatic_session(server_a)
graph = json.loads((workspace_a / "core-snapshot.json").read_text()) graph = json.loads((workspace_a / "core-snapshot.json").read_text())
seed = graph["nodes"][0]["id"] seed = graph["nodes"][0]["id"]
prepared = {"selection_ids": [seed], prepared = {"selection_ids": [seed],
@ -191,7 +190,7 @@ class ResearchWebTest(unittest.TestCase):
self.assertEqual(len(list((workspace_a / ".labfy/research").glob("*.txt"))), 2) self.assertEqual(len(list((workspace_a / ".labfy/research").glob("*.txt"))), 2)
_, server_b = self.start_workspace("B") _, server_b = self.start_workspace("B")
cookie_b, _ = self.login(server_b) cookie_b, _ = self.open_automatic_session(server_b)
status, _, body = self.request(server_b, "GET", "/api/v1/research", status, _, body = self.request(server_b, "GET", "/api/v1/research",
cookie=cookie_b) cookie=cookie_b)
self.assertEqual(status, 200); self.assertEqual(json.loads(body)["state"], "EMPTY") self.assertEqual(status, 200); self.assertEqual(json.loads(body)["state"], "EMPTY")
@ -202,7 +201,7 @@ class ResearchWebTest(unittest.TestCase):
def test_result_failure_compensates_final_artifact(self): def test_result_failure_compensates_final_artifact(self):
Provider.requests.clear() Provider.requests.clear()
workspace, server = self.start_workspace("artifact-failure") workspace, server = self.start_workspace("artifact-failure")
cookie, csrf = self.login(server) cookie, csrf = self.open_automatic_session(server)
graph = json.loads((workspace / "core-snapshot.json").read_text()) graph = json.loads((workspace / "core-snapshot.json").read_text())
prepared = {"selection_ids": [graph["nodes"][0]["id"]], prepared = {"selection_ids": [graph["nodes"][0]["id"]],
"question": "Faute SQLite SPECIMEN ?", "exclusions": [], "question": "Faute SQLite SPECIMEN ?", "exclusions": [],
@ -243,7 +242,7 @@ class ResearchWebTest(unittest.TestCase):
Provider.bodies["/specimen/wave-1"] = b"x" * 4097 Provider.bodies["/specimen/wave-1"] = b"x" * 4097
try: try:
workspace, server = self.start_workspace("response-limit") workspace, server = self.start_workspace("response-limit")
cookie, csrf = self.login(server) cookie, csrf = self.open_automatic_session(server)
graph = json.loads((workspace / "core-snapshot.json").read_text()) graph = json.loads((workspace / "core-snapshot.json").read_text())
prepared = {"selection_ids": [graph["nodes"][0]["id"]], prepared = {"selection_ids": [graph["nodes"][0]["id"]],
"question": "Borne corps SPECIMEN ?", "exclusions": [], "question": "Borne corps SPECIMEN ?", "exclusions": [],
@ -281,7 +280,7 @@ class ResearchWebTest(unittest.TestCase):
def test_crash_after_rename_reconciles_without_recontact(self): def test_crash_after_rename_reconciles_without_recontact(self):
Provider.requests.clear() Provider.requests.clear()
workspace, server = self.start_workspace("crash-reconcile") workspace, server = self.start_workspace("crash-reconcile")
cookie, csrf = self.login(server) cookie, csrf = self.open_automatic_session(server)
graph = json.loads((workspace / "core-snapshot.json").read_text()) graph = json.loads((workspace / "core-snapshot.json").read_text())
prepared = {"selection_ids": [graph["nodes"][0]["id"]], prepared = {"selection_ids": [graph["nodes"][0]["id"]],
"question": "Reprise publication SPECIMEN ?", "exclusions": [], "question": "Reprise publication SPECIMEN ?", "exclusions": [],
@ -328,7 +327,7 @@ class ResearchWebTest(unittest.TestCase):
def test_research_routes_keep_host_origin_csrf_and_shape_guards(self): def test_research_routes_keep_host_origin_csrf_and_shape_guards(self):
_, server = self.start_workspace("guards") _, server = self.start_workspace("guards")
cookie, csrf = self.login(server) cookie, csrf = self.open_automatic_session(server)
self.assertEqual(self.request(server, "GET", "/api/v1/research", self.assertEqual(self.request(server, "GET", "/api/v1/research",
cookie=cookie, host="evil.test")[0], 403) cookie=cookie, host="evil.test")[0], 403)
value = {"selection_ids": ["x"], "question": "SPECIMEN", value = {"selection_ids": ["x"], "question": "SPECIMEN",

View file

@ -59,10 +59,8 @@ class SessionLifetimeTest(unittest.TestCase):
connection.close() connection.close()
def login(self): def login(self):
status, headers, body = self.request("POST", "/api/v1/session", status, headers, body = self.request("GET", "/")
body={"bootstrap_code": "code-SPECIMEN-only"}) self.assertEqual(status, 303)
self.assertEqual(status, 200)
self.assertTrue(json.loads(body)["authenticated"])
self.assertIn("HttpOnly", headers["Set-Cookie"]) self.assertIn("HttpOnly", headers["Set-Cookie"])
self.assertIn("SameSite=Strict", headers["Set-Cookie"]) self.assertIn("SameSite=Strict", headers["Set-Cookie"])
self.assertIn("Max-Age=3600", headers["Set-Cookie"]) self.assertIn("Max-Age=3600", headers["Set-Cookie"])
@ -105,19 +103,17 @@ class SessionLifetimeTest(unittest.TestCase):
self.assertNotIn(b'id="login-form"', html) self.assertNotIn(b'id="login-form"', html)
self.assertEqual(self.server.session_deadline, self.now + 3600) self.assertEqual(self.server.session_deadline, self.now + 3600)
def test_invalid_login_never_extends_or_replaces_a_session(self): def test_bootstrap_route_is_absent_and_never_extends_a_session(self):
cookie = self.login() cookie = self.login()
before = (self.server.session, self.server.csrf, self.server.session_deadline) before = (self.server.session, self.server.csrf, self.server.session_deadline)
for expired in (False, True): for expired in (False, True):
if expired: if expired:
self.now += 3601 self.now += 3601
for code, origin in (("wrong-SPECIMEN", self.server.origin), status, _, _ = self.request("POST", "/api/v1/session",
("code-SPECIMEN-only", "http://foreign.test")): body={}, origin=self.server.origin)
status, _, _ = self.request("POST", "/api/v1/session", self.assertEqual(status, 404)
body={"bootstrap_code": code}, origin=origin) self.assertEqual((self.server.session, self.server.csrf,
self.assertEqual(status, 403) self.server.session_deadline), before)
self.assertEqual((self.server.session, self.server.csrf,
self.server.session_deadline), before)
self.assertEqual(self.request("GET", "/api/v1/session", cookie=cookie)[0], self.assertEqual(self.request("GET", "/api/v1/session", cookie=cookie)[0],
401 if expired else 200) 401 if expired else 200)

View file

@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import puppeteer from "puppeteer-core"; import puppeteer from "puppeteer-core";
import { openAutomaticSession } from "./browser_automatic_session.mjs";
const PROTOTYPE = new URL("..", import.meta.url); const PROTOTYPE = new URL("..", import.meta.url);
const FIREFOX = process.env.FIREFOX_PATH ?? "/usr/bin/firefox"; const FIREFOX = process.env.FIREFOX_PATH ?? "/usr/bin/firefox";
@ -21,11 +22,11 @@ inactive.mkdir(mode=0o700, exist_ok=True)
# CONTRACT: expiration réelle et courte, injectée uniquement dans ce processus # CONTRACT: expiration réelle et courte, injectée uniquement dans ce processus
# de test. Aucune route ne permet de modifier l'horloge ou le TTL. # de test. Aucune route ne permet de modifier l'horloge ou le TTL.
server = WorkspaceServer(("127.0.0.1", 0), Handler, workspace=inactive, server = WorkspaceServer(("127.0.0.1", 0), Handler, workspace=inactive,
bridge=bridge, bootstrap="session-browser-SPECIMEN", library=library, bridge=bridge, library=library,
instance_id="session-browser-SPECIMEN", config_id="session-browser-SPECIMEN", instance_id="session-browser-SPECIMEN", config_id="session-browser-SPECIMEN",
session_ttl_seconds=5) session_ttl_seconds=8)
print(json.dumps({"origin": server.origin, print(json.dumps({"origin": server.origin,
"code": "session-browser-SPECIMEN"}), flush=True) "contract": "labfy.session-lifetime.fixture.v1"}), flush=True)
signal.signal(signal.SIGTERM, signal.signal(signal.SIGTERM,
lambda _signum, _frame: threading.Thread(target=server.shutdown, lambda _signum, _frame: threading.Thread(target=server.shutdown,
daemon=True).start()) daemon=True).start())
@ -71,17 +72,6 @@ async function stopServer(server) {
await exited; await exited;
} }
async function login(page, session) {
await page.goto(session.origin, { waitUntil: "domcontentloaded" });
await page.waitForSelector("#login-form");
await page.type("#bootstrap-code", session.code);
await Promise.all([
page.waitForNavigation({ waitUntil: "domcontentloaded" }),
page.click("#login-form button"),
]);
await page.waitForSelector("#library-home:not([hidden])");
}
async function createAndOpenSpecimen(page) { async function createAndOpenSpecimen(page) {
const title = "Enquête session Firefox SPECIMEN"; const title = "Enquête session Firefox SPECIMEN";
await page.type("#workspace-title", title); await page.type("#workspace-title", title);
@ -109,9 +99,6 @@ try {
const session = await ready(server); const session = await ready(server);
assert.notEqual(new URL(session.origin).port, "8081"); assert.notEqual(new URL(session.origin).port, "8081");
assert.notEqual(new URL(session.origin).port, "8080"); assert.notEqual(new URL(session.origin).port, "8080");
// CONTRACT: le serveur peut être déjà ancien quand Firefox soumet son premier
// formulaire. La connexion réussie doit alors commencer une nouvelle durée.
await delay(5500);
browser = await puppeteer.launch({ browser: "firefox", executablePath: FIREFOX, browser = await puppeteer.launch({ browser: "firefox", executablePath: FIREFOX,
headless: true, userDataDir: profile, args: ["--no-remote"] }); headless: true, userDataDir: profile, args: ["--no-remote"] });
const page = await browser.newPage(); const page = await browser.newPage();
@ -123,7 +110,7 @@ try {
unauthorized.push(response.url()); unauthorized.push(response.url());
}); });
await login(page, session); await openAutomaticSession(page, session.origin);
const workspaceId = await createAndOpenSpecimen(page); const workspaceId = await createAndOpenSpecimen(page);
assert.ok(workspaceId); assert.ok(workspaceId);
await page.$eval("#report-comment", (input) => { await page.$eval("#report-comment", (input) => {
@ -138,18 +125,14 @@ try {
cookie.name.startsWith("labfy_session_")); cookie.name.startsWith("labfy_session_"));
assert.ok(oldCookie); assert.ok(oldCookie);
// WHY: un polling réel doit découvrir l'expiration et arrêter ses trois // WHY: le polling réel observe un seul 401 puis la navigation racine détruit
// pairs ; le test n'appelle aucune route d'administration. // son contexte ; aucune route d'administration ne force l'expiration.
await page.waitForSelector("#session-reconnect", { timeout: 10000 }); const automaticReturn = page.waitForNavigation({ waitUntil: "domcontentloaded",
const expiredState = await page.evaluate(() => window.__LABFY_TEST__.getState()); timeout: 10000 });
assert.equal(expiredState.sessionExpired, true); await automaticReturn;
assert.equal(expiredState.refreshTimerCount, 0); await page.waitForSelector("#library-home:not([hidden])", { timeout: 10000 });
assert.equal(expiredState.hasCsrf, false); assert.equal(await page.$("#login-form"), null);
assert.equal(expiredState.workspaceId, null); assert.equal(await page.$("#bootstrap-code"), null);
assert.equal(await page.$eval("#workbench-shell", (item) => item.hidden), true);
assert.match(await page.$eval("#library-connection", (item) => item.textContent),
/travail en cours a été arrêté/i);
assert.equal(await page.$$eval("#session-reconnect", (items) => items.length), 1);
const requestCount = unauthorized.length; const requestCount = unauthorized.length;
await new Promise((resolve) => setTimeout(resolve, 1400)); await new Promise((resolve) => setTimeout(resolve, 1400));
assert.equal(unauthorized.length, requestCount, assert.equal(unauthorized.length, requestCount,
@ -158,14 +141,10 @@ try {
assert.equal(await page.evaluate(() => Object.keys(sessionStorage) assert.equal(await page.evaluate(() => Object.keys(sessionStorage)
.some((key) => key.startsWith("labfy-report-draft:"))), true); .some((key) => key.startsWith("labfy-report-draft:"))), true);
await page.click("#session-reconnect"); const renewedCookie = (await page.cookies()).find((cookie) =>
await page.waitForSelector("#login-form"); cookie.name.startsWith("labfy_session_"));
await page.type("#bootstrap-code", session.code); assert.ok(renewedCookie);
await Promise.all([ assert.notEqual(renewedCookie.value, oldCookie.value);
page.waitForNavigation({ waitUntil: "domcontentloaded" }),
page.click("#login-form button"),
]);
await page.waitForSelector("#library-home:not([hidden])");
const renewed = await page.evaluate(async () => { const renewed = await page.evaluate(async () => {
const response = await fetch("/api/v1/session", { cache: "no-store" }); const response = await fetch("/api/v1/session", { cache: "no-store" });
return response.json(); return response.json();
@ -187,7 +166,7 @@ try {
body: "{}", body: "{}",
})).status, 403); })).status, 403);
assert.deepEqual(pageErrors, []); assert.deepEqual(pageErrors, []);
console.log("PASS Firefox session : expiration réelle, arrêt unique, reconnexion, " + console.log("PASS Firefox session : expiration réelle, arrêt unique, reconnexion automatique, " +
"contexte SPECIMEN et rotation cookie/CSRF"); "contexte SPECIMEN et rotation cookie/CSRF");
} finally { } finally {
if (browser) await browser.close(); if (browser) await browser.close();

View file

@ -0,0 +1,173 @@
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawn, spawnSync } from "node:child_process";
import puppeteer from "puppeteer-core";
import { openAutomaticSession } from "./browser_automatic_session.mjs";
const REPOSITORY = new URL("../../..", import.meta.url);
const PROTOTYPE = new URL("..", import.meta.url);
const FIREFOX = process.env.FIREFOX_PATH ?? "/usr/bin/firefox";
function ready(server) {
return new Promise((resolve, reject) => {
let output = "";
const timer = setTimeout(() => reject(new Error("démarrage expiré")), 10000);
server.stdout.on("data", (chunk) => {
output += chunk;
const url = output.match(/http:\/\/127\.0\.0\.1:\d+\//);
if (url) {
clearTimeout(timer);
resolve({ url: url[0] });
}
});
server.once("exit", (code) => reject(new Error(`serveur arrêté (${code})`)));
});
}
async function screenshot(page, name) {
await page.screenshot({ path: `/tmp/labfy-visual-polish-${name}.png` });
}
async function assertNoHorizontalOverflow(page) {
assert.deepEqual(
await page.evaluate(() =>
document.documentElement.scrollWidth <= document.documentElement.clientWidth,
),
true,
);
}
const workspace = await mkdtemp(join(tmpdir(), "labfy-visual-polish-"));
const profile = await mkdtemp(join(tmpdir(), "labfy-visual-polish-firefox-"));
let browser;
let server;
try {
const init = spawnSync(
"./tools/local-jobs",
["init-j7-specimen", "--workspace", workspace],
{ cwd: REPOSITORY, encoding: "utf8", timeout: 30000 },
);
assert.equal(init.status, 0, init.stderr);
spawnSync("./tools/local-jobs", ["export", "--workspace", workspace], {
cwd: REPOSITORY, timeout: 10000,
});
server = spawn("python3", [
"workspace_server.py", "--workspace", workspace, "--bridge",
"../../tools/local-jobs", "--port", "0",
], {
cwd: PROTOTYPE,
env: { ...process.env, PYTHONUNBUFFERED: "1" },
stdio: ["ignore", "pipe", "pipe"],
});
const session = await ready(server);
browser = await puppeteer.launch({
browser: "firefox", executablePath: FIREFOX, headless: true,
userDataDir: profile, args: ["--no-remote"],
});
const page = await browser.newPage();
const errors = [];
page.on("pageerror", (error) => errors.push(String(error)));
await page.setViewport({ width: 1440, height: 900 });
await openAutomaticSession(page, session.url);
await page.waitForSelector(".node");
await assertNoHorizontalOverflow(page);
await screenshot(page, "1440-network");
await page.focus("#agent-prompt");
await page.type("#agent-prompt", "Prépare une piste SPECIMEN sans lancer d’action.");
await page.keyboard.down("Control");
await page.keyboard.press("Enter");
await page.keyboard.up("Control");
assert.equal(await page.$eval("#agent-prompt", (element) => element.value), "");
assert.match(
await page.$eval(".agent-conversation", (element) => element.textContent),
/Prépare une piste SPECIMEN/,
);
await page.type("#agent-prompt", "Brouillon SPECIMEN à conserver pendant le défilement.");
await page.$eval(".agent-scroll", (element) => { element.scrollTop = element.scrollHeight; });
assert.deepEqual(
await page.$eval("#agent-prompt", (element) => ({
value: element.value,
visible: element.getBoundingClientRect().bottom > 0,
})),
{ value: "Brouillon SPECIMEN à conserver pendant le défilement.", visible: true },
);
await page.click('[data-pane="agent-panel"]');
await page.click('[data-pane="agent-panel"]');
assert.equal(
await page.$eval("#agent-prompt", (element) => element.value),
"Brouillon SPECIMEN à conserver pendant le défilement.",
);
await screenshot(page, "1440-agent");
await page.click(".agent-card.authorization button");
await screenshot(page, "1440-authorization");
await page.select("#activity-filter", "TOOL");
assert.equal(await page.$$eval("[data-activity-type]:not([hidden])", (items) => items.length), 1);
await page.click("#activity-expert-toggle");
await page.click('[data-activity-type="TOOL"] summary');
await screenshot(page, "1440-activity");
await page.click(".node");
await page.click('[data-work-panel="provenance-drawer-panel"]');
assert.match(
await page.$eval("#drawer-provenance-summary", (element) => element.textContent),
/Provenance disponible/,
);
await screenshot(page, "1440-provenance");
const firstNode = await page.$(".node");
const box = await firstNode.boundingBox();
assert.ok(box);
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 + 54, box.y + box.height / 2 + 28, { steps: 5 });
await page.mouse.up();
assert.ok(Object.keys(await page.evaluate(() => window.__LABFY_TEST__.getState().pinned)).length > 0);
await screenshot(page, "1440-pinned-selection");
const agentSeparator = await page.$("#agent-resizer");
const separatorBox = await agentSeparator.boundingBox();
assert.ok(separatorBox);
await page.mouse.move(separatorBox.x + 2, separatorBox.y + 80);
await page.mouse.down();
await page.mouse.move(separatorBox.x + 42, separatorBox.y + 80, { steps: 3 });
await page.mouse.up();
const layoutBeforeReload = await page.evaluate(() => localStorage.getItem("labfy-layout:agent"));
assert.ok(Number(layoutBeforeReload) >= 208);
await page.reload({ waitUntil: "domcontentloaded" });
await page.waitForSelector(".node");
assert.equal(await page.evaluate(() => localStorage.getItem("labfy-layout:agent")), layoutBeforeReload);
await page.click("#graph-fullscreen");
assert.equal(await page.$eval(".workbench", (element) => element.classList.contains("graph-priority")), true);
await screenshot(page, "1440-graph-priority");
await page.keyboard.press("Escape");
assert.equal(await page.$eval(".workbench", (element) => element.classList.contains("graph-priority")), false);
for (const [width, height, name] of [[1280, 800, "1280"], [1024, 768, "1024"], [700, 900, "700"]]) {
await page.setViewport({ width, height });
await page.waitForFunction(() => document.querySelector("#graph-panel").getBoundingClientRect().height > 0);
await assertNoHorizontalOverflow(page);
await screenshot(page, name);
}
await page.click("#work-panel-toggle");
await screenshot(page, "700-drawer-collapsed");
await page.click("#work-panel-toggle");
await page.evaluate(() => window.__LABFY_TEST__.processEventData("{invalid"));
assert.match(await page.$eval("#graph-state", (element) => element.textContent), /JSON illisible/);
await screenshot(page, "700-error");
assert.deepEqual(errors, []);
console.log("PASS visual polish Firefox : 11 captures, panels, persistance, prompt, graphe et erreur");
} finally {
if (browser) await browser.close();
if (server) {
server.kill("SIGINT");
await new Promise((resolve) => server.once("exit", resolve));
}
await rm(profile, { recursive: true, force: true });
await rm(workspace, { recursive: true, force: true });
}

View file

@ -78,31 +78,27 @@ class LauncherTest(SyntheticEnvironment):
self.assertEqual(parser.parse_args(["serve", "--workspace", self.assertEqual(parser.parse_args(["serve", "--workspace",
str(self.root / "w"), "--port", "0"]).port, 0) str(self.root / "w"), "--port", "0"]).port, 0)
def test_xdg_start_status_code_double_start_and_stop(self): def test_xdg_start_status_auto_session_double_start_and_stop(self):
workspace = self.root / "workspace" workspace = self.root / "workspace"
started = self.command("start", "--workspace", str(workspace), "--port", "0", started = self.command("start", "--workspace", str(workspace), "--port", "0",
"--bridge", str(self.bridge)) "--bridge", str(self.bridge))
self.assertEqual(started.returncode, 0, started.stderr) self.assertEqual(started.returncode, 0, started.stderr)
self.assertIn("Code de session éphémère : ", started.stdout) self.assertIn("Labfy Web : http://127.0.0.1:", started.stdout)
bootstrap = started.stdout.split("Code de session éphémère : ", 1)[1].strip() self.assertNotIn("Code de session", started.stdout)
instance = (self.runtime / web_app.APP_DIRECTORY / "instance.json") instance = (self.runtime / web_app.APP_DIRECTORY / "instance.json")
self.assertTrue(instance.is_file()) self.assertTrue(instance.is_file())
self.assertNotIn(bootstrap, instance.read_text(encoding="utf-8")) self.assertIn('"automatic_session":true', instance.read_text(encoding="utf-8"))
self.assertEqual(instance.stat().st_mode & 0o777, 0o600) self.assertEqual(instance.stat().st_mode & 0o777, 0o600)
self.assertEqual((self.runtime / web_app.APP_DIRECTORY).stat().st_mode & 0o777, self.assertEqual((self.runtime / web_app.APP_DIRECTORY).stat().st_mode & 0o777,
0o700) 0o700)
config = self.state / web_app.APP_DIRECTORY / "config.json" config = self.state / web_app.APP_DIRECTORY / "config.json"
self.assertEqual(config.stat().st_mode & 0o777, 0o600) self.assertEqual(config.stat().st_mode & 0o777, 0o600)
self.assertNotIn(bootstrap, config.read_text(encoding="utf-8"))
status = self.command("status") status = self.command("status")
self.assertEqual(status.returncode, 0, status.stderr) self.assertEqual(status.returncode, 0, status.stderr)
self.assertIn("actif", status.stdout) self.assertIn("actif", status.stdout)
code = self.command("code")
self.assertEqual(code.returncode, 0, code.stderr)
self.assertEqual(code.stdout.strip(), bootstrap)
duplicate = self.command("start", "--workspace", str(workspace), "--port", "0", duplicate = self.command("start", "--workspace", str(workspace), "--port", "0",
"--bridge", str(self.bridge)) "--bridge", str(self.bridge))
self.assertNotEqual(duplicate.returncode, 0) self.assertEqual(duplicate.returncode, 0, duplicate.stderr)
self.assertIn("déjà active", duplicate.stderr) self.assertIn("déjà active", duplicate.stderr)
stopped = self.command("stop") stopped = self.command("stop")
self.assertEqual(stopped.returncode, 0, stopped.stderr) self.assertEqual(stopped.returncode, 0, stopped.stderr)
@ -266,15 +262,13 @@ class LibraryTest(SyntheticEnvironment):
data = response.read() data = response.read()
if response.getheader("Set-Cookie"): if response.getheader("Set-Cookie"):
cookie = response.getheader("Set-Cookie").split(";", 1)[0] cookie = response.getheader("Set-Cookie").split(";", 1)[0]
result = response.status, json.loads(data) result = response.status, (json.loads(data) if data else {})
connection.close() connection.close()
return result return result
try: try:
self.assertEqual(request("GET", "/api/v1/library", authenticated=False)[0], 401) self.assertEqual(request("GET", "/api/v1/library", authenticated=False)[0], 401)
self.assertEqual(request("POST", "/api/v1/session", self.assertEqual(request("GET", "/", authenticated=False)[0], 303)
{"bootstrap_code": "SPECIMEN-bootstrap"}, authenticated=False,
csrf=False)[0], 200)
self.assertEqual(request("POST", "/api/v1/library/workspaces", self.assertEqual(request("POST", "/api/v1/library/workspaces",
{"title": "Sans CSRF", "idempotency_key": "x"}, csrf=False)[0], 403) {"title": "Sans CSRF", "idempotency_key": "x"}, csrf=False)[0], 403)
status, empty = request("GET", "/api/v1/library") status, empty = request("GET", "/api/v1/library")

View file

@ -5,56 +5,67 @@ import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { spawn, spawnSync } from "node:child_process"; import { spawn, spawnSync } from "node:child_process";
import puppeteer from "puppeteer-core"; import puppeteer from "puppeteer-core";
import { openAutomaticSession, resumeAutomaticSessionAfterOwnedRestart } from "./browser_automatic_session.mjs";
const REPOSITORY = new URL("../../..", import.meta.url);
const PROTOTYPE = new URL("..", import.meta.url); const PROTOTYPE = new URL("..", import.meta.url);
const FIREFOX = process.env.FIREFOX_PATH ?? "/usr/bin/firefox"; const FIREFOX = process.env.FIREFOX_PATH ?? "/usr/bin/firefox";
function startServer(library, runtime, state, port = 0) { function startServer(library, runtime, state, port = 0) {
return spawn("python3", ["web_app.py", "serve", "--library", library, const result = spawnSync("./tools/labfy", ["--library", library,
"--bridge", "../../tools/local-jobs", "--port", String(port)], { "--port", String(port)], {
cwd: PROTOTYPE, cwd: REPOSITORY,
env: { ...process.env, PYTHONUNBUFFERED: "1", XDG_RUNTIME_DIR: runtime, env: { ...process.env, PYTHONUNBUFFERED: "1", XDG_RUNTIME_DIR: runtime,
XDG_STATE_HOME: state }, XDG_STATE_HOME: state },
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
}); });
assert.equal(result.status, 0, result.stderr.toString());
return { output: result.stdout.toString(), runtime, state };
} }
function ready(server) { function ready(server) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let output = ""; let output = "";
const timer = setTimeout(() => reject(new Error("démarrage Web expiré")), 10000); const timer = setTimeout(() => reject(new Error("démarrage Web expiré")), 10000);
server.stdout.on("data", (chunk) => { output += server.output;
output += chunk; const origin = output.match(/Labfy Web : (http:\/\/127\.0\.0\.1:\d+)\//);
const origin = output.match(/Labfy Web : (http:\/\/127\.0\.0\.1:\d+)\//); if (origin) {
const code = output.match(/Code de session éphémère : ([^\s]+)/); clearTimeout(timer);
if (origin && code) { resolve({ origin: origin[1] });
clearTimeout(timer); return;
resolve({ origin: origin[1], code: code[1] }); }
} clearTimeout(timer);
}); reject(new Error(`lanceur Web sans origine: ${output}`));
server.once("exit", (code) => reject(new Error(`serveur arrêté (${code})`)));
}); });
} }
async function stopServer(server) { async function stopServer(server) {
if (!server || server.exitCode !== null) return; if (!server) return;
const exited = new Promise((resolve) => server.once("exit", resolve)); const result = spawnSync("python3", ["web_app.py", "stop"], {
server.kill("SIGTERM"); cwd: PROTOTYPE,
await exited; env: { ...process.env, XDG_RUNTIME_DIR: server.runtime,
XDG_STATE_HOME: server.state },
encoding: "utf8",
});
assert.equal(result.status, 0, result.stderr);
} }
async function login(page, session) { async function assertServerReady(server, expectedOrigin) {
try { const origin = (await ready(server)).origin;
await page.evaluate((origin) => window.location.assign(origin), session.origin); assert.equal(origin, expectedOrigin);
} catch (error) { const health = await fetch(`${origin}/healthz`, { cache: "no-store" });
if (!String(error).includes("Execution context was destroyed")) throw error; assert.equal(health.status, 200);
} const body = await health.json();
await page.waitForSelector("#login-form"); assert.equal(body.contract, "labfy.workspace.health.v1");
await page.type("#bootstrap-code", session.code); return origin;
await page.click("#login-form button"); }
await page.waitForSelector("#library-home:not([hidden])");
assert.equal(page.url().includes(session.code), false); async function detachPageFromOwnedServer(page) {
assert.equal((await page.content()).includes(session.code), false); // CONTRACT: avant d'arrêter un backend possédé, le client de test quitte son
// EventSource afin que Firefox ne réutilise pas une navigation interrompue
// lorsque la même origine est relancée.
await page.evaluate(() => window.location.replace("about:blank"));
await page.waitForFunction(() => location.href === "about:blank");
} }
async function createWorkspace(page, title) { async function createWorkspace(page, title) {
@ -129,7 +140,7 @@ try {
const errors = []; const errors = [];
page.on("pageerror", (error) => errors.push(String(error))); page.on("pageerror", (error) => errors.push(String(error)));
await page.setViewport({ width: 700, height: 900 }); await page.setViewport({ width: 700, height: 900 });
await login(page, session); await openAutomaticSession(page, session.origin, "A");
assert.equal(await page.$eval("#workspace-list", (item) => item.children.length), 0); assert.equal(await page.$eval("#workspace-list", (item) => item.children.length), 0);
assert.match(await page.$eval("#library-empty", (item) => item.textContent), /Aucune enquête/); assert.match(await page.$eval("#library-empty", (item) => item.textContent), /Aucune enquête/);
@ -296,11 +307,11 @@ try {
// Le port initial est attribué par le système. Sa réutilisation conserve le // Le port initial est attribué par le système. Sa réutilisation conserve le
// même origin et permet de vérifier les brouillons session après redémarrage. // même origin et permet de vérifier les brouillons session après redémarrage.
await detachPageFromOwnedServer(page);
await stopServer(server); await stopServer(server);
server = startServer(library, runtime, state, assignedPort); server = startServer(library, runtime, state, assignedPort);
const restartedForB = await ready(server); const restartedForB = await assertServerReady(server, session.origin);
assert.equal(restartedForB.origin, session.origin); await resumeAutomaticSessionAfterOwnedRestart(page, restartedForB, "B");
await login(page, restartedForB);
assert.equal(await page.$eval("#workspace-list", (item) => item.children.length), 1); assert.equal(await page.$eval("#workspace-list", (item) => item.children.length), 1);
assert.equal(await page.$eval(".workspace-card h3", (item) => item.textContent), workspaceATitle); assert.equal(await page.$eval(".workspace-card h3", (item) => item.textContent), workspaceATitle);
assert.match(await page.$eval(".workspace-card p", (item) => item.textContent), /Prête à ouvrir/); assert.match(await page.$eval(".workspace-card p", (item) => item.textContent), /Prête à ouvrir/);
@ -332,11 +343,11 @@ try {
await page.click("#home-button"); await page.click("#home-button");
await page.waitForSelector("#library-home:not([hidden])"); await page.waitForSelector("#library-home:not([hidden])");
await detachPageFromOwnedServer(page);
await stopServer(server); await stopServer(server);
server = startServer(library, runtime, state, assignedPort); server = startServer(library, runtime, state, assignedPort);
const restartedForA = await ready(server); const restartedForA = await assertServerReady(server, session.origin);
assert.equal(restartedForA.origin, session.origin); await resumeAutomaticSessionAfterOwnedRestart(page, restartedForA, "C");
await login(page, restartedForA);
assert.equal(await page.$eval("#workspace-list", (item) => item.children.length), 2); assert.equal(await page.$eval("#workspace-list", (item) => item.children.length), 2);
await openWorkspace(page, workspaceAId); await openWorkspace(page, workspaceAId);
assert.equal(await page.$eval("#investigation-title", (item) => item.textContent), assert.equal(await page.$eval("#investigation-title", (item) => item.textContent),

View file

@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { spawn, spawnSync } from "node:child_process"; import { spawn, spawnSync } from "node:child_process";
import puppeteer from "puppeteer-core"; import puppeteer from "puppeteer-core";
import { openAutomaticSession } from "./browser_automatic_session.mjs";
const REPOSITORY = new URL("../../..", import.meta.url), const REPOSITORY = new URL("../../..", import.meta.url),
PROTOTYPE = new URL("..", import.meta.url); PROTOTYPE = new URL("..", import.meta.url);
@ -14,11 +15,10 @@ function ready(server) {
const timer = setTimeout(() => reject(new Error("démarrage expiré")), 8000); const timer = setTimeout(() => reject(new Error("démarrage expiré")), 8000);
server.stdout.on("data", (chunk) => { server.stdout.on("data", (chunk) => {
text += chunk; text += chunk;
const url = text.match(/http:\/\/127\.0\.0\.1:(\d+)\//), const url = text.match(/http:\/\/127\.0\.0\.1:(\d+)\//);
code = text.match(/Code de session éphémère : ([^\s]+)/); if (url) {
if (url && code) {
clearTimeout(timer); clearTimeout(timer);
resolve({ url: url[0], code: code[1] }); resolve({ url: url[0] });
} }
}); });
server.once("exit", (value) => server.once("exit", (value) =>
@ -26,14 +26,6 @@ function ready(server) {
); );
}); });
} }
async function login(page, session) {
await page.goto(session.url, { waitUntil: "domcontentloaded" });
await page.type("#bootstrap-code", session.code);
await Promise.all([
page.waitForNavigation({ waitUntil: "domcontentloaded" }),
page.click("#login-form button"),
]);
}
const workspace = await mkdtemp(join(tmpdir(), "labfy-workbench-")), const workspace = await mkdtemp(join(tmpdir(), "labfy-workbench-")),
profile = await mkdtemp(join(tmpdir(), "labfy-workbench-firefox-")); profile = await mkdtemp(join(tmpdir(), "labfy-workbench-firefox-"));
@ -81,7 +73,7 @@ try {
page.on("pageerror", (error) => errors.push(String(error))); page.on("pageerror", (error) => errors.push(String(error)));
await page.setViewport({ width: 1440, height: 900 }); await page.setViewport({ width: 1440, height: 900 });
const start = Date.now(); const start = Date.now();
await login(page, session); await openAutomaticSession(page, session.url);
await page.waitForSelector(".node"); await page.waitForSelector(".node");
const metrics = await page.evaluate(() => ({ const metrics = await page.evaluate(() => ({
nodes: document.querySelectorAll(".node").length, nodes: document.querySelectorAll(".node").length,

View file

@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { spawn, spawnSync } from "node:child_process"; import { spawn, spawnSync } from "node:child_process";
import puppeteer from "puppeteer-core"; import puppeteer from "puppeteer-core";
import { openAutomaticSession } from "./browser_automatic_session.mjs";
const REPOSITORY = new URL("../../..", import.meta.url); const REPOSITORY = new URL("../../..", import.meta.url);
const PROTOTYPE = new URL("..", import.meta.url); const PROTOTYPE = new URL("..", import.meta.url);
@ -19,10 +20,9 @@ function ready(server) {
server.stdout.on("data", (chunk) => { server.stdout.on("data", (chunk) => {
output += chunk; output += chunk;
const url = output.match(/http:\/\/127\.0\.0\.1:\d+\//); const url = output.match(/http:\/\/127\.0\.0\.1:\d+\//);
const code = output.match(/Code de session éphémère : ([^\s]+)/); if (url) {
if (url && code) {
clearTimeout(timer); clearTimeout(timer);
resolve({ url: url[0], code: code[1] }); resolve({ url: url[0] });
} }
}); });
server.once("exit", (code) => server.once("exit", (code) =>
@ -31,14 +31,6 @@ function ready(server) {
}); });
} }
async function login(page, session) {
await page.goto(session.url, { waitUntil: "domcontentloaded" });
await page.type("#bootstrap-code", session.code);
await Promise.all([
page.waitForNavigation({ waitUntil: "domcontentloaded" }),
page.click("#login-form button"),
]);
}
const workspace = await mkdtemp(join(tmpdir(), "labfy-workbench-populated-")); const workspace = await mkdtemp(join(tmpdir(), "labfy-workbench-populated-"));
const profile = await mkdtemp( const profile = await mkdtemp(
@ -89,7 +81,7 @@ try {
await page.setViewport({ width: 1440, height: 900 }); await page.setViewport({ width: 1440, height: 900 });
const authenticationStarted = performance.now(); const authenticationStarted = performance.now();
await login(page, session); await openAutomaticSession(page, session.url);
await page.waitForSelector(".node"); await page.waitForSelector(".node");
const firstRenderMs = Math.round(performance.now() - authenticationStarted); const firstRenderMs = Math.round(performance.now() - authenticationStarted);

View file

@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { spawn, spawnSync } from "node:child_process"; import { spawn, spawnSync } from "node:child_process";
import puppeteer from "puppeteer-core"; import puppeteer from "puppeteer-core";
import { openAutomaticSession } from "./browser_automatic_session.mjs";
const REPOSITORY = new URL("../../..", import.meta.url); const REPOSITORY = new URL("../../..", import.meta.url);
const PROTOTYPE = new URL("..", import.meta.url); const PROTOTYPE = new URL("..", import.meta.url);
@ -16,10 +17,9 @@ function waitForServer(server) {
server.stdout.on("data", (chunk) => { server.stdout.on("data", (chunk) => {
output += chunk.toString(); output += chunk.toString();
const url = output.match(/http:\/\/127\.0\.0\.1:(\d+)\//); const url = output.match(/http:\/\/127\.0\.0\.1:(\d+)\//);
const code = output.match(/Code de session éphémère : ([^\s]+)/); if (url) {
if (url && code) {
clearTimeout(timeout); clearTimeout(timeout);
resolve({ url: url[0], code: code[1] }); resolve({ url: url[0] });
} }
}); });
server.once("exit", (code) => reject(new Error(`serveur J6 arrêté (${code})`))); server.once("exit", (code) => reject(new Error(`serveur J6 arrêté (${code})`)));
@ -62,11 +62,7 @@ try {
headless: true, userDataDir: profile, args: ["--no-remote"] }); headless: true, userDataDir: profile, args: ["--no-remote"] });
const page = await browser.newPage(); const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 900 }); await page.setViewport({ width: 1440, height: 900 });
await page.goto(session.url, { waitUntil: "domcontentloaded" }); await openAutomaticSession(page, session.url);
assert.ok(await page.$("#login-form"));
await page.type("#bootstrap-code", session.code);
await Promise.all([page.waitForNavigation({ waitUntil: "domcontentloaded" }),
page.click("#login-form button")]);
await page.waitForSelector(`[data-id="evidence:${manifest.eml_id}"]`); await page.waitForSelector(`[data-id="evidence:${manifest.eml_id}"]`);
assert.match(await page.$eval("#mode-badge", (item) => item.textContent), /Poste local/); assert.match(await page.$eval("#mode-badge", (item) => item.textContent), /Poste local/);

View file

@ -35,11 +35,8 @@ class WorkspaceServerTest(unittest.TestCase):
bridge=cls.bridge, bootstrap="SPECIMEN-CODE") bridge=cls.bridge, bootstrap="SPECIMEN-CODE")
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True) cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
cls.thread.start() cls.thread.start()
status, headers, _ = cls.request_raw( status, headers, _ = cls.request_raw("GET", "/")
"POST", "/api/v1/session", {"bootstrap_code": "SPECIMEN-CODE"}, if status != 303:
origin=cls.server.origin,
)
if status != 200:
raise RuntimeError("session J6 impossible") raise RuntimeError("session J6 impossible")
cls.cookie = headers["Set-Cookie"].split(";", 1)[0] cls.cookie = headers["Set-Cookie"].split(";", 1)[0]
status, _, body = cls.request_raw("GET", "/api/v1/session", cookie=cls.cookie) status, _, body = cls.request_raw("GET", "/api/v1/session", cookie=cls.cookie)
@ -85,8 +82,7 @@ class WorkspaceServerTest(unittest.TestCase):
def test_authentication_origin_csrf_and_input_rejections(self): def test_authentication_origin_csrf_and_input_rejections(self):
self.assertEqual(self.request_raw( self.assertEqual(self.request_raw(
"POST", "/api/v1/session", {"bootstrap_code": ["SPECIMEN-CODE"]}, "POST", "/api/v1/session", {}, origin=self.server.origin)[0], 404)
origin=self.server.origin)[0], 400)
self.assertEqual(self.request_raw("GET", "/api/v1/jobs")[0], 401) self.assertEqual(self.request_raw("GET", "/api/v1/jobs")[0], 401)
self.assertEqual(self.request_raw("GET", "/api/v1/jobs", host="evil.test")[0], 403) self.assertEqual(self.request_raw("GET", "/api/v1/jobs", host="evil.test")[0], 403)
self.assertEqual(self.request_raw("POST", "/api/v1/queue/pause", {}, self.assertEqual(self.request_raw("POST", "/api/v1/queue/pause", {},

View file

@ -133,7 +133,6 @@ class InstanceFiles:
self.runtime = runtime_directory() self.runtime = runtime_directory()
self.state_root = state_directory() self.state_root = state_directory()
self.instance = self.runtime / "instance.json" self.instance = self.runtime / "instance.json"
self.secret = self.runtime / "bootstrap.code"
self.config = self.state_root / "config.json" self.config = self.state_root / "config.json"
self.lifecycle = self.runtime / "lifecycle.lock" self.lifecycle = self.runtime / "lifecycle.lock"
self.owner = self.runtime / "instance.lock" self.owner = self.runtime / "instance.lock"
@ -144,7 +143,7 @@ class InstanceFiles:
return descriptor return descriptor
def clear_stale(self): def clear_stale(self):
for path in (self.instance, self.secret): for path in (self.instance,):
try: try:
path.unlink() path.unlink()
except FileNotFoundError: except FileNotFoundError:
@ -177,7 +176,6 @@ def serve(args):
selection = _selection(args) selection = _selection(args)
config, config_id = _config(selection, args.bridge) config, config_id = _config(selection, args.bridge)
instance_id = secrets.token_urlsafe(24) instance_id = secrets.token_urlsafe(24)
bootstrap = secrets.token_urlsafe(18)
library = None library = None
if selection[0] == "library": if selection[0] == "library":
library = WebLibrary(selection[1], args.bridge) library = WebLibrary(selection[1], args.bridge)
@ -188,7 +186,7 @@ def serve(args):
workspace.mkdir(mode=0o700, parents=True, exist_ok=True) workspace.mkdir(mode=0o700, parents=True, exist_ok=True)
try: try:
server = WorkspaceServer(("127.0.0.1", args.port), Handler, server = WorkspaceServer(("127.0.0.1", args.port), Handler,
workspace=workspace, bridge=args.bridge, bootstrap=bootstrap, workspace=workspace, bridge=args.bridge, bootstrap="",
library=library, instance_id=instance_id, config_id=config_id, library=library, instance_id=instance_id, config_id=config_id,
automatic_session=args.automatic_session) automatic_session=args.automatic_session)
except OSError as error: except OSError as error:
@ -196,28 +194,21 @@ def serve(args):
"error": f"Port indisponible : {error}"}) "error": f"Port indisponible : {error}"})
os.close(owner) os.close(owner)
return 1 return 1
state = {"contract": "labfy.web_instance.v1", "pid": os.getpid(), state = {"contract": "labfy.web_instance.v2", "pid": os.getpid(),
"proc_start": _proc_start(os.getpid()), "instance_id": instance_id, "proc_start": _proc_start(os.getpid()), "instance_id": instance_id,
"config_id": config_id, "port": server.server_port, "config": config} "config_id": config_id, "port": server.server_port, "config": config,
"automatic_session": True}
try: try:
# CONTRACT: la configuration durable ne contient jamais le secret # CONTRACT: la configuration durable ne contient jamais le secret
# d'amorçage, qui reste exclusivement dans le runtime privé. # d'amorçage, qui reste exclusivement dans le runtime privé.
_atomic_json(files.config, {"contract": "labfy.web_config.v1", _atomic_json(files.config, {"contract": "labfy.web_config.v1",
"config_id": config_id, **config}) "config_id": config_id, **config})
_atomic_json(files.instance, state) _atomic_json(files.instance, state)
descriptor = os.open(files.secret, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW,
0o600)
try:
os.write(descriptor, (bootstrap + "\n").encode())
finally:
os.close(descriptor)
answer = {"ok": True, "origin": server.origin, answer = {"ok": True, "origin": server.origin,
"instance_id": instance_id} "instance_id": instance_id}
_handshake(args.handshake_fd, answer) _handshake(args.handshake_fd, answer)
if args.handshake_fd is None: if args.handshake_fd is None:
print(f"Labfy Web : {server.origin}/", flush=True) print(f"Labfy Web : {server.origin}/", flush=True)
if not args.automatic_session:
print(f"Code de session éphémère : {bootstrap}", flush=True)
# INVARIANT: SIGTERM n'arrête que cette instance déjà authentifiée # INVARIANT: SIGTERM n'arrête que cette instance déjà authentifiée
# par le lanceur ; shutdown s'exécute hors du gestionnaire de signal. # par le lanceur ; shutdown s'exécute hors du gestionnaire de signal.
def request_shutdown(_signum, _frame): def request_shutdown(_signum, _frame):
@ -247,17 +238,31 @@ def start(args):
current = None current = None
if current is not None and _identity_valid(current): if current is not None and _identity_valid(current):
origin = f"http://127.0.0.1:{current['port']}/" origin = f"http://127.0.0.1:{current['port']}/"
if args.open_browser: if (current.get("automatic_session") is True and
webbrowser.open(origin) current.get("contract") == "labfy.web_instance.v2"):
print(f"Instance déjà active sur {origin}", file=sys.stderr) if args.open_browser:
return 0 webbrowser.open(origin)
print(f"Instance déjà active sur {origin}", file=sys.stderr)
return 0
# CONTRACT: une instance authentifiée mais d'ancien profil ne peut
# jamais être rejointe : elle ramènerait le parcours code retiré.
print(f"Instance incompatible sur {origin} ; remplacement contrôlé",
file=sys.stderr)
os.kill(current["pid"], signal.SIGTERM)
deadline = time.monotonic() + 5
while (time.monotonic() < deadline and
_proc_start(current["pid"]) == current["proc_start"]):
time.sleep(0.05)
if _proc_start(current["pid"]) == current["proc_start"]:
print("Arrêt de l'instance incompatible expiré", file=sys.stderr)
return 1
files.clear_stale()
files.clear_stale() files.clear_stale()
read_fd, write_fd = os.pipe() read_fd, write_fd = os.pipe()
command = [sys.executable, str(Path(__file__).resolve()), "serve", command = [sys.executable, str(Path(__file__).resolve()), "serve",
"--port", str(args.port), "--bridge", str(args.bridge), "--port", str(args.port), "--bridge", str(args.bridge),
"--handshake-fd", str(write_fd)] "--handshake-fd", str(write_fd)]
if args.automatic_session: command.append("--automatic-session")
command.append("--automatic-session")
mode, path = _selection(args) mode, path = _selection(args)
command.extend((f"--{mode}", str(path))) command.extend((f"--{mode}", str(path)))
process = subprocess.Popen(command, stdin=subprocess.DEVNULL, process = subprocess.Popen(command, stdin=subprocess.DEVNULL,
@ -329,21 +334,6 @@ def stop(_args):
os.close(lifecycle) os.close(lifecycle)
def code(_args):
files = InstanceFiles()
try:
state = _read_private_json(files.instance)
if not _identity_valid(state):
raise RuntimeError("Instance inactive")
if files.secret.is_symlink() or not files.secret.is_file():
raise RuntimeError("Code indisponible")
print(files.secret.read_text(encoding="utf-8").strip())
return 0
except (OSError, RuntimeError, json.JSONDecodeError) as error:
print(str(error), file=sys.stderr)
return 1
def build_parser(): def build_parser():
parser = argparse.ArgumentParser(description="Poste Web local Labfy") parser = argparse.ArgumentParser(description="Poste Web local Labfy")
subparsers = parser.add_subparsers(dest="command", required=True) subparsers = parser.add_subparsers(dest="command", required=True)
@ -363,7 +353,7 @@ def build_parser():
if name == "serve": if name == "serve":
command.add_argument("--handshake-fd", type=int, help=argparse.SUPPRESS) command.add_argument("--handshake-fd", type=int, help=argparse.SUPPRESS)
command.set_defaults(function=function) command.set_defaults(function=function)
for name, function in (("status", status), ("stop", stop), ("code", code)): for name, function in (("status", status), ("stop", stop)):
command = subparsers.add_parser(name) command = subparsers.add_parser(name)
command.set_defaults(function=function) command.set_defaults(function=function)
help_command = subparsers.add_parser("help") help_command = subparsers.add_parser("help")

View file

@ -48,7 +48,7 @@ class WorkspaceServer(ThreadingHTTPServer):
block_on_close = False block_on_close = False
def __init__(self, address, handler, *, workspace: Path, bridge: Path, def __init__(self, address, handler, *, workspace: Path, bridge: Path,
bootstrap: str, library=None, instance_id=None, config_id=None, bootstrap: str = "", library=None, instance_id=None, config_id=None,
session_ttl_seconds=SESSION_TTL_SECONDS, session_ttl_seconds=SESSION_TTL_SECONDS,
research_fixture_authority=None, automatic_session=False): research_fixture_authority=None, automatic_session=False):
if (not isinstance(session_ttl_seconds, int) or if (not isinstance(session_ttl_seconds, int) or
@ -59,10 +59,10 @@ class WorkspaceServer(ThreadingHTTPServer):
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}"
self.bootstrap = bootstrap # CONTRACT: le poste Web ne possède plus de parcours code. Toute
# CONTRACT: ce mode est activé seulement par le lanceur possédé. Il # première navigation loopback établit seulement un cookie HttpOnly ;
# émet un cookie HttpOnly local mais ne relâche jamais Origin/CSRF. # Host, Origin et CSRF restent exigés pour chaque mutation.
self.automatic_session = automatic_session self.automatic_session = True
self.library = library self.library = library
self.active_workspace_id = None self.active_workspace_id = None
self.library_generation = (library.snapshot(None)["generation"] self.library_generation = (library.snapshot(None)["generation"]
@ -561,13 +561,20 @@ class Handler(BaseHTTPRequestHandler):
"status": "ready", "instance_id": self.server.instance_id, "status": "ready", "instance_id": self.server.instance_id,
"config_id": self.server.config_id}) "config_id": self.server.config_id})
return return
if path == "/favicon.ico":
# CONTRACT: les navigateurs demandent implicitement cette icône ;
# l'absence d'asset ne doit pas transformer un parcours sain en 404.
self.send_response(HTTPStatus.NO_CONTENT)
self._headers()
self.end_headers()
return
if path in {"/login.html", "/login.js"}:
self._error(HTTPStatus.NOT_FOUND, "route_unknown", "Route inconnue")
return
if not self._authenticated(): if not self._authenticated():
if path == "/" and self.server.automatic_session: if path == "/":
self._open_automatic_session() self._open_automatic_session()
return return
if path in {"/", "/login.js", "/styles.css"}:
self._serve_static("/login.html" if path == "/" else path)
return
self._error(HTTPStatus.UNAUTHORIZED, "session_required", self._error(HTTPStatus.UNAUTHORIZED, "session_required",
"Session locale requise") "Session locale requise")
return return
@ -670,7 +677,7 @@ class Handler(BaseHTTPRequestHandler):
return return
path = urlsplit(self.path).path path = urlsplit(self.path).path
if path == "/api/v1/session": if path == "/api/v1/session":
self._open_session() self._error(HTTPStatus.NOT_FOUND, "route_unknown", "Route inconnue")
return return
if not self._mutation_allowed(): if not self._mutation_allowed():
self._error(HTTPStatus.FORBIDDEN, "mutation_rejected", self._error(HTTPStatus.FORBIDDEN, "mutation_rejected",
@ -1118,31 +1125,6 @@ class Handler(BaseHTTPRequestHandler):
except FileNotFoundError:pass except FileNotFoundError:pass
self._json(HTTPStatus.OK,{"contract":"labfy.local_upload.v1","state":"CANCELLED"}) self._json(HTTPStatus.OK,{"contract":"labfy.local_upload.v1","state":"CANCELLED"})
def _open_session(self):
if self.headers.get("Origin") != self.server.origin:
self._error(HTTPStatus.FORBIDDEN, "origin_rejected", "Origin exact obligatoire")
return
try:
value = self._body({"bootstrap_code"})
except (TypeError, OverflowError) as error:
self._error(HTTPStatus.BAD_REQUEST, "invalid_request", str(error))
return
if not hmac.compare_digest(value["bootstrap_code"], self.server.bootstrap):
self._error(HTTPStatus.FORBIDDEN, "bootstrap_rejected", "Code éphémère invalide")
return
with self.server.session_lock:
# Une reconnexion renouvelle les secrets ; les anciens cookies
# expirés ne doivent pas redevenir valables.
self.server.session = secrets.token_urlsafe(32)
self.server.csrf = secrets.token_urlsafe(32)
self.server.session_deadline = (time.monotonic() +
self.server.session_ttl_seconds)
cookie = (f"{self.server.cookie_name}={self.server.session}; "
"HttpOnly; SameSite=Strict; "
f"Path=/; Max-Age={self.server.session_ttl_seconds}")
self._json(HTTPStatus.OK, {"contract": "labfy.workspace.session.v1",
"authenticated": True}, cookie=cookie)
def _open_automatic_session(self): def _open_automatic_session(self):
with self.server.session_lock: with self.server.session_lock:
self.server.session = secrets.token_urlsafe(32) self.server.session = secrets.token_urlsafe(32)
@ -1213,12 +1195,10 @@ def main():
return parsed return parsed
parser.add_argument("--port", type=port, default=8081) parser.add_argument("--port", type=port, default=8081)
args = parser.parse_args() args = parser.parse_args()
bootstrap = secrets.token_urlsafe(12)
server = WorkspaceServer(("127.0.0.1", args.port), Handler, server = WorkspaceServer(("127.0.0.1", args.port), Handler,
workspace=args.workspace, bridge=args.bridge, workspace=args.workspace, bridge=args.bridge,
bootstrap=bootstrap) bootstrap="")
print(f"Labfy J6 : {server.origin}/", flush=True) print(f"Labfy J6 : {server.origin}/", flush=True)
print(f"Code de session éphémère : {bootstrap}", flush=True)
try: try:
server.serve_forever() server.serve_forever()
except KeyboardInterrupt: except KeyboardInterrupt: