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

View file

@ -7,15 +7,17 @@
## Parcours
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
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,
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
L'instance écoute sur `127.0.0.1:8081` dans cet exemple. La navigation racine
établit automatiquement une session locale HttpOnly et ouvre directement la
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
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

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
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
| Validation | Résultat observé |

View file

@ -187,18 +187,9 @@ function expireAuthenticatedSession() {
byId("library-home").hidden = false;
byId("workspace-create").hidden = true;
byId("library-list-section").hidden = true;
byId("library-title").textContent = "Session locale expirée";
byId("library-connection").textContent =
"Le travail en cours a été arrêté. Reconnectez-vous pour reprendre ; aucune action refusée n’a été relancée.";
let reconnect = byId("session-reconnect");
if (!reconnect) {
reconnect = document.createElement("a");
reconnect.id = "session-reconnect";
reconnect.href = "/";
reconnect.textContent = "Se reconnecter";
byId("library-connection").insertAdjacentElement("afterend", reconnect);
}
reconnect.focus();
// CONTRACT: la reconnexion automatique ne rejoue aucune mutation. La
// navigation racine obtient un nouveau cookie HttpOnly local sans code ni URL.
window.location.replace("/");
}
async function detectExpiredSession(response) {
@ -282,11 +273,20 @@ function invalidateReportPreview(
}
async function postCommand(path, value = {}) {
const response = await workspaceFetch(path, {
const request = () => workspaceFetch(path, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Labfy-CSRF": csrfToken },
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);
if (!response.ok) throw new Error(result.message ?? "Commande refusée");
return result;
@ -995,6 +995,7 @@ function render({ focusDomNodeId = null } = {}) {
offset,
focusNodeId: visibleIds.has(activeNodeId) ? activeNodeId : null,
});
byId("graph-zoom").textContent = `${Math.round(scale * 100)} %`;
renderObjectList();
renderSelection();
}
@ -1025,6 +1026,13 @@ function renderSelection() {
details.textContent = "Sélectionnez un objet.";
actions.textContent = "Aucune action sans sélection valide.";
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;
}
@ -1074,6 +1082,16 @@ function renderSelection() {
if (!provenanceMirror.hasChildNodes())
provenanceMirror.textContent = "Aucune origine supplémentaire persistée.";
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) {
@ -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() {
// 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.
@ -1590,6 +1677,26 @@ function configureControls() {
offset = { x: 0, y: 0 };
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) => {
filters.type = event.target.value;
render();
@ -1629,7 +1736,7 @@ function configureGraphInteractions() {
// coordonnées épinglées qui restent un état de présentation local.
new ResizeObserver(() => requestAnimationFrame(() => {
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) {
// 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
@ -1674,9 +1781,10 @@ function configureGraphInteractions() {
event.preventDefault();
scale = Math.max(
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);
byId("graph-zoom").textContent = `${Math.round(scale * 100)} %`;
},
{ passive: false },
);
@ -1719,6 +1827,12 @@ function configureGraphInteractions() {
byId("node-context-menu").hidden = true;
svg.focus();
}
if (
event.key === "Escape" &&
document.querySelector(".workbench").classList.contains("graph-priority")
) {
byId("graph-fullscreen").click();
}
});
document.addEventListener("pointerdown", (event) => {
if (
@ -1834,6 +1948,7 @@ async function activateWorkspace(opened) {
// repose pas sur la largeur héritée lorsque le shell était hidden.
await new Promise((resolve) => requestAnimationFrame(resolve));
scale = fitGraphScale();
graph_scale_initialized = true;
syncTypeFilterOptions();
restoreReportDraft();
render();
@ -1884,6 +1999,7 @@ async function activateLegacyWorkspace(session) {
coreMode ? "Lecture seule — snapshot cœur chargé" : `Connecté · révision ${snapshot.revision}`;
scale = coreMode ? fitGraphScale() :
Math.min(1, Math.max(0.6, (svg.clientWidth - 160) / 700));
graph_scale_initialized = true;
syncTypeFilterOptions();
restoreReportDraft();
render();
@ -1941,12 +2057,13 @@ async function openWorkspace(workspaceId, button) {
function configureApplication() {
configureControls();
configureResizableLayout();
configureGraphInteractions();
configureImport();
const drawerCollapsed = localStorage.getItem("labfy-pane:drawer") === "collapsed";
if (drawerCollapsed && !byId("work-panel").classList.contains("collapsed"))
byId("work-panel-toggle").click();
byId("agent-send").addEventListener("click", () => {
const submitAgentPrompt = () => {
const prompt = byId("agent-prompt").value.trim();
if (!prompt) { byId("agent-prompt").focus(); return; }
const card = document.createElement("li");
@ -1956,6 +2073,13 @@ function configureApplication() {
card.append(label, text);
document.querySelector(".agent-conversation").append(card);
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("workspace-create-form").addEventListener("submit", async (event) => {

View file

@ -56,7 +56,7 @@
placeholder="Rechercher un objet…"
/><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>
<nav class="projection-bar" aria-label="Projections et contrôles du graphe">
<div class="projection-tabs" role="group">
@ -93,19 +93,24 @@
<main class="workbench">
<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>
<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-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>
<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 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 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 warning"><b>WARNING</b><p>Les hypothèses restent distinctes des observations et des décisions humaines.</p></li>
</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>
</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">
<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 class="graph-legend" aria-label="Légende">
<span><i class="shape round"></i>Entité</span
@ -166,10 +171,11 @@
</div>
<button class="pane-toggle" data-pane="inspector" aria-expanded="true">Réduire</button>
</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">
<div class="activity-entry"><time>15:42:12</time><b>TOOL</b><span>rdap.lookup_domain<br/><code>target = example.test</code></span></div>
<div class="activity-entry policy"><time>15:42:12</time><b>POLICY</b><span>Autorisé · budget restant 18/20</span></div>
<div class="activity-entry result"><time>15:42:13</time><b>RESULT</b><span>1 artefact · 7 observations</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>
<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>
<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>
<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">
@ -211,26 +217,46 @@
</table>
</section>
</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">
<div class="work-panel-header">
<div class="tablist" role="tablist">
<button
role="tab"
data-work-panel="tasks-panel"
aria-selected="true"
>
Tâches</button
<button role="tab" data-work-panel="details-drawer-panel">Détails</button
><button role="tab" data-work-panel="provenance-drawer-panel">Provenance</button
><button role="tab" data-work-panel="timeline-drawer-panel">Timeline</button
><button role="tab" data-work-panel="research-panel">Recherches</button
><button role="tab" data-work-panel="observations-drawer-panel">Observations</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">
Prochaines actions</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>
<button id="work-panel-toggle" aria-expanded="true">Réduire</button>
</div>
<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">
<h3>Jobs locaux</h3>
<p id="jobs-note">Chargement…</p>
@ -331,6 +357,7 @@
</section>
</div>
</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>
<dialog id="evidence-dialog" aria-labelledby="evidence-title">
<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 {
--base: #1e1e2e;
--mantle: #181825;
--crust: #11111b;
--panel: #181825;
--surface0: #313244;
--surface1: #45475a;
--raised: #313244;
--border: #45475a;
--accent: #b4befe;
@ -12,7 +16,7 @@
--yellow: #f9e2af;
--top: 3.6rem;
--nav: 3.3rem;
--drawer: 15rem;
--drawer: 240px;
}
.modal-card { position: fixed; inset: 50% auto auto 50%; transform: translate(-50%,-50%);
@ -465,6 +469,7 @@ aside h4,
}
.work-panel.collapsed {
grid-template-rows: auto 0;
overflow: hidden;
}
.work-panel-header {
display: flex;
@ -656,7 +661,7 @@ body.login .workbench {
display: none;
}
.workbench.drawer-collapsed {
grid-template-rows: minmax(0, 1fr) auto;
grid-template-rows: minmax(0, 1fr) 2.7rem;
}
#evidence-dialog {
@ -734,14 +739,39 @@ body.login .workbench {
}
/* CONTRACT: la coque finale garde le graphe dominant ; les trois surfaces
* 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"; }
.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); }
.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; }
.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.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; }
.topbar-status { display: flex; align-items: center; justify-content: end; gap: .45rem; min-width: 0; }
.icon-button, .graph-toolbar button { display: inline-grid; place-items: center; min-width: 2.1rem; min-height: 2.1rem; padding: .25rem; }
.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"; }
.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); }
.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) {
.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 {
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.
export const scenarios = Object.freeze([
"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_eml_browser.mjs",
"test_local_toolkit_browser.mjs",
@ -19,6 +22,9 @@ export const scenarios = Object.freeze([
"test_j9_browser.mjs",
"test_workbench_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",
// 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.

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

View file

@ -4,18 +4,18 @@ 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),PROTOTYPE=new URL("..",import.meta.url);
const FIREFOX_PATH=process.env.FIREFOX_PATH??"/usr/bin/firefox";
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 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 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});
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));}
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});}}

View file

@ -5,6 +5,7 @@ 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),
PROTOTYPE = new URL("..", import.meta.url),
FIREFOX = process.env.FIREFOX_PATH ?? "/usr/bin/firefox";
@ -17,11 +18,10 @@ function ready(server) {
);
server.stdout.on("data", (chunk) => {
text += chunk;
const u = text.match(/http:\/\/127\.0\.0\.1:(\d+)\//),
c = text.match(/Code de session éphémère : ([^\s]+)/);
if (u && c) {
const u = text.match(/http:\/\/127\.0\.0\.1:(\d+)\//);
if (u) {
clearTimeout(timer);
resolve({ url: u[0], code: c[1] });
resolve({ url: u[0] });
}
});
server.once("exit", (code) =>
@ -52,14 +52,6 @@ async function stop(server) {
server.kill("SIGINT");
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-")),
profile = await mkdtemp(join(tmpdir(), "labfy-j9-firefox-"));
let browser, server;
@ -96,7 +88,7 @@ try {
const errors = [];
page.on("pageerror", (e) => errors.push(String(e)));
await page.setViewport({ width: 1440, height: 900 });
await login(page, session);
await openAutomaticSession(page, session.url);
await page.waitForSelector("#view-evidence");
await page.click("#view-evidence");
await page.click("#view-timeline");

View file

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

View file

@ -5,6 +5,7 @@ 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);
@ -17,10 +18,9 @@ function ready(server) {
server.stdout.on("data", (chunk) => {
text += chunk;
const url = text.match(/http:\/\/127\.0\.0\.1:(\d+)\//);
const code = text.match(/Code de session éphémère : ([^\s]+)/);
if (url && code) {
if (url) {
clearTimeout(timer);
resolve({ url: url[0], code: code[1] });
resolve({ url: url[0] });
}
});
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));
}
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) {
const result = spawnSync("./tools/local-jobs",
@ -81,7 +75,7 @@ try {
headless: true, userDataDir: profile, args: ["--no-remote"] });
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 900 });
await login(page, sessionA);
await openAutomaticSession(page, sessionA.url);
await page.waitForSelector("#object-list button");
await page.click("#object-list button");
await page.click('[data-work-panel="research-panel"]');
@ -108,7 +102,7 @@ try {
await stop(server); server = start(workspaceB, authority);
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"]');
assert.equal(await pageB.$$eval(".research-card", (nodes) => nodes.length), 0);
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)],
cwd=cls.repository, check=True, timeout=15)
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}")
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start(); cls.instances.append((server, thread))
@ -87,10 +87,9 @@ class ResearchWebTest(unittest.TestCase):
result = response.status, dict(response.getheaders()), data
connection.close(); return result
def login(self, server):
status, headers, _ = self.request(server, "POST", "/api/v1/session",
{"bootstrap_code": "SPECIMEN-RESEARCH"}, origin=server.origin)
self.assertEqual(status, 200)
def open_automatic_session(self, server):
status, headers, _ = self.request(server, "GET", "/")
self.assertEqual(status, 303)
cookie = headers["Set-Cookie"].split(";", 1)[0]
status, _, body = self.request(server, "GET", "/api/v1/session",
cookie=cookie)
@ -105,7 +104,7 @@ class ResearchWebTest(unittest.TestCase):
def test_two_waves_refusal_never_contacts_and_workspace_b_isolated(self):
Provider.requests.clear()
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())
seed = graph["nodes"][0]["id"]
prepared = {"selection_ids": [seed],
@ -191,7 +190,7 @@ class ResearchWebTest(unittest.TestCase):
self.assertEqual(len(list((workspace_a / ".labfy/research").glob("*.txt"))), 2)
_, 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",
cookie=cookie_b)
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):
Provider.requests.clear()
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())
prepared = {"selection_ids": [graph["nodes"][0]["id"]],
"question": "Faute SQLite SPECIMEN ?", "exclusions": [],
@ -243,7 +242,7 @@ class ResearchWebTest(unittest.TestCase):
Provider.bodies["/specimen/wave-1"] = b"x" * 4097
try:
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())
prepared = {"selection_ids": [graph["nodes"][0]["id"]],
"question": "Borne corps SPECIMEN ?", "exclusions": [],
@ -281,7 +280,7 @@ class ResearchWebTest(unittest.TestCase):
def test_crash_after_rename_reconciles_without_recontact(self):
Provider.requests.clear()
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())
prepared = {"selection_ids": [graph["nodes"][0]["id"]],
"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):
_, 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",
cookie=cookie, host="evil.test")[0], 403)
value = {"selection_ids": ["x"], "question": "SPECIMEN",

View file

@ -59,10 +59,8 @@ class SessionLifetimeTest(unittest.TestCase):
connection.close()
def login(self):
status, headers, body = self.request("POST", "/api/v1/session",
body={"bootstrap_code": "code-SPECIMEN-only"})
self.assertEqual(status, 200)
self.assertTrue(json.loads(body)["authenticated"])
status, headers, body = self.request("GET", "/")
self.assertEqual(status, 303)
self.assertIn("HttpOnly", headers["Set-Cookie"])
self.assertIn("SameSite=Strict", 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.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()
before = (self.server.session, self.server.csrf, self.server.session_deadline)
for expired in (False, True):
if expired:
self.now += 3601
for code, origin in (("wrong-SPECIMEN", self.server.origin),
("code-SPECIMEN-only", "http://foreign.test")):
status, _, _ = self.request("POST", "/api/v1/session",
body={"bootstrap_code": code}, origin=origin)
self.assertEqual(status, 403)
self.assertEqual((self.server.session, self.server.csrf,
self.server.session_deadline), before)
status, _, _ = self.request("POST", "/api/v1/session",
body={}, origin=self.server.origin)
self.assertEqual(status, 404)
self.assertEqual((self.server.session, self.server.csrf,
self.server.session_deadline), before)
self.assertEqual(self.request("GET", "/api/v1/session", cookie=cookie)[0],
401 if expired else 200)

View file

@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawn } from "node:child_process";
import puppeteer from "puppeteer-core";
import { openAutomaticSession } from "./browser_automatic_session.mjs";
const PROTOTYPE = new URL("..", import.meta.url);
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
# de test. Aucune route ne permet de modifier l'horloge ou le TTL.
server = WorkspaceServer(("127.0.0.1", 0), Handler, workspace=inactive,
bridge=bridge, bootstrap="session-browser-SPECIMEN", library=library,
bridge=bridge, library=library,
instance_id="session-browser-SPECIMEN", config_id="session-browser-SPECIMEN",
session_ttl_seconds=5)
session_ttl_seconds=8)
print(json.dumps({"origin": server.origin,
"code": "session-browser-SPECIMEN"}), flush=True)
"contract": "labfy.session-lifetime.fixture.v1"}), flush=True)
signal.signal(signal.SIGTERM,
lambda _signum, _frame: threading.Thread(target=server.shutdown,
daemon=True).start())
@ -71,17 +72,6 @@ async function stopServer(server) {
await exited;
}
async function login(page, session) {
await page.goto(session.origin, { waitUntil: "domcontentloaded" });
await page.waitForSelector("#login-form");
await page.type("#bootstrap-code", session.code);
await Promise.all([
page.waitForNavigation({ waitUntil: "domcontentloaded" }),
page.click("#login-form button"),
]);
await page.waitForSelector("#library-home:not([hidden])");
}
async function createAndOpenSpecimen(page) {
const title = "Enquête session Firefox SPECIMEN";
await page.type("#workspace-title", title);
@ -109,9 +99,6 @@ try {
const session = await ready(server);
assert.notEqual(new URL(session.origin).port, "8081");
assert.notEqual(new URL(session.origin).port, "8080");
// CONTRACT: le serveur peut être déjà ancien quand Firefox soumet son premier
// formulaire. La connexion réussie doit alors commencer une nouvelle durée.
await delay(5500);
browser = await puppeteer.launch({ browser: "firefox", executablePath: FIREFOX,
headless: true, userDataDir: profile, args: ["--no-remote"] });
const page = await browser.newPage();
@ -123,7 +110,7 @@ try {
unauthorized.push(response.url());
});
await login(page, session);
await openAutomaticSession(page, session.origin);
const workspaceId = await createAndOpenSpecimen(page);
assert.ok(workspaceId);
await page.$eval("#report-comment", (input) => {
@ -138,18 +125,14 @@ try {
cookie.name.startsWith("labfy_session_"));
assert.ok(oldCookie);
// WHY: un polling réel doit découvrir l'expiration et arrêter ses trois
// pairs ; le test n'appelle aucune route d'administration.
await page.waitForSelector("#session-reconnect", { timeout: 10000 });
const expiredState = await page.evaluate(() => window.__LABFY_TEST__.getState());
assert.equal(expiredState.sessionExpired, true);
assert.equal(expiredState.refreshTimerCount, 0);
assert.equal(expiredState.hasCsrf, false);
assert.equal(expiredState.workspaceId, null);
assert.equal(await page.$eval("#workbench-shell", (item) => item.hidden), true);
assert.match(await page.$eval("#library-connection", (item) => item.textContent),
/travail en cours a été arrêté/i);
assert.equal(await page.$$eval("#session-reconnect", (items) => items.length), 1);
// WHY: le polling réel observe un seul 401 puis la navigation racine détruit
// son contexte ; aucune route d'administration ne force l'expiration.
const automaticReturn = page.waitForNavigation({ waitUntil: "domcontentloaded",
timeout: 10000 });
await automaticReturn;
await page.waitForSelector("#library-home:not([hidden])", { timeout: 10000 });
assert.equal(await page.$("#login-form"), null);
assert.equal(await page.$("#bootstrap-code"), null);
const requestCount = unauthorized.length;
await new Promise((resolve) => setTimeout(resolve, 1400));
assert.equal(unauthorized.length, requestCount,
@ -158,14 +141,10 @@ try {
assert.equal(await page.evaluate(() => Object.keys(sessionStorage)
.some((key) => key.startsWith("labfy-report-draft:"))), true);
await page.click("#session-reconnect");
await page.waitForSelector("#login-form");
await page.type("#bootstrap-code", session.code);
await Promise.all([
page.waitForNavigation({ waitUntil: "domcontentloaded" }),
page.click("#login-form button"),
]);
await page.waitForSelector("#library-home:not([hidden])");
const renewedCookie = (await page.cookies()).find((cookie) =>
cookie.name.startsWith("labfy_session_"));
assert.ok(renewedCookie);
assert.notEqual(renewedCookie.value, oldCookie.value);
const renewed = await page.evaluate(async () => {
const response = await fetch("/api/v1/session", { cache: "no-store" });
return response.json();
@ -187,7 +166,7 @@ try {
body: "{}",
})).status, 403);
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");
} finally {
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",
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"
started = self.command("start", "--workspace", str(workspace), "--port", "0",
"--bridge", str(self.bridge))
self.assertEqual(started.returncode, 0, started.stderr)
self.assertIn("Code de session éphémère : ", started.stdout)
bootstrap = started.stdout.split("Code de session éphémère : ", 1)[1].strip()
self.assertIn("Labfy Web : http://127.0.0.1:", started.stdout)
self.assertNotIn("Code de session", started.stdout)
instance = (self.runtime / web_app.APP_DIRECTORY / "instance.json")
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((self.runtime / web_app.APP_DIRECTORY).stat().st_mode & 0o777,
0o700)
config = self.state / web_app.APP_DIRECTORY / "config.json"
self.assertEqual(config.stat().st_mode & 0o777, 0o600)
self.assertNotIn(bootstrap, config.read_text(encoding="utf-8"))
status = self.command("status")
self.assertEqual(status.returncode, 0, status.stderr)
self.assertIn("actif", status.stdout)
code = self.command("code")
self.assertEqual(code.returncode, 0, code.stderr)
self.assertEqual(code.stdout.strip(), bootstrap)
duplicate = self.command("start", "--workspace", str(workspace), "--port", "0",
"--bridge", str(self.bridge))
self.assertNotEqual(duplicate.returncode, 0)
self.assertEqual(duplicate.returncode, 0, duplicate.stderr)
self.assertIn("déjà active", duplicate.stderr)
stopped = self.command("stop")
self.assertEqual(stopped.returncode, 0, stopped.stderr)
@ -266,15 +262,13 @@ class LibraryTest(SyntheticEnvironment):
data = response.read()
if response.getheader("Set-Cookie"):
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()
return result
try:
self.assertEqual(request("GET", "/api/v1/library", authenticated=False)[0], 401)
self.assertEqual(request("POST", "/api/v1/session",
{"bootstrap_code": "SPECIMEN-bootstrap"}, authenticated=False,
csrf=False)[0], 200)
self.assertEqual(request("GET", "/", authenticated=False)[0], 303)
self.assertEqual(request("POST", "/api/v1/library/workspaces",
{"title": "Sans CSRF", "idempotency_key": "x"}, csrf=False)[0], 403)
status, empty = request("GET", "/api/v1/library")

View file

@ -5,56 +5,67 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawn, spawnSync } from "node:child_process";
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 FIREFOX = process.env.FIREFOX_PATH ?? "/usr/bin/firefox";
function startServer(library, runtime, state, port = 0) {
return spawn("python3", ["web_app.py", "serve", "--library", library,
"--bridge", "../../tools/local-jobs", "--port", String(port)], {
cwd: PROTOTYPE,
const result = spawnSync("./tools/labfy", ["--library", library,
"--port", String(port)], {
cwd: REPOSITORY,
env: { ...process.env, PYTHONUNBUFFERED: "1", XDG_RUNTIME_DIR: runtime,
XDG_STATE_HOME: state },
stdio: ["ignore", "pipe", "pipe"],
});
assert.equal(result.status, 0, result.stderr.toString());
return { output: result.stdout.toString(), runtime, state };
}
function ready(server) {
return new Promise((resolve, reject) => {
let output = "";
const timer = setTimeout(() => reject(new Error("démarrage Web expiré")), 10000);
server.stdout.on("data", (chunk) => {
output += chunk;
const origin = output.match(/Labfy Web : (http:\/\/127\.0\.0\.1:\d+)\//);
const code = output.match(/Code de session éphémère : ([^\s]+)/);
if (origin && code) {
clearTimeout(timer);
resolve({ origin: origin[1], code: code[1] });
}
});
server.once("exit", (code) => reject(new Error(`serveur arrêté (${code})`)));
output += server.output;
const origin = output.match(/Labfy Web : (http:\/\/127\.0\.0\.1:\d+)\//);
if (origin) {
clearTimeout(timer);
resolve({ origin: origin[1] });
return;
}
clearTimeout(timer);
reject(new Error(`lanceur Web sans origine: ${output}`));
});
}
async function stopServer(server) {
if (!server || server.exitCode !== null) return;
const exited = new Promise((resolve) => server.once("exit", resolve));
server.kill("SIGTERM");
await exited;
if (!server) return;
const result = spawnSync("python3", ["web_app.py", "stop"], {
cwd: PROTOTYPE,
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) {
try {
await page.evaluate((origin) => window.location.assign(origin), session.origin);
} catch (error) {
if (!String(error).includes("Execution context was destroyed")) throw error;
}
await page.waitForSelector("#login-form");
await page.type("#bootstrap-code", session.code);
await page.click("#login-form button");
await page.waitForSelector("#library-home:not([hidden])");
assert.equal(page.url().includes(session.code), false);
assert.equal((await page.content()).includes(session.code), false);
async function assertServerReady(server, expectedOrigin) {
const origin = (await ready(server)).origin;
assert.equal(origin, expectedOrigin);
const health = await fetch(`${origin}/healthz`, { cache: "no-store" });
assert.equal(health.status, 200);
const body = await health.json();
assert.equal(body.contract, "labfy.workspace.health.v1");
return origin;
}
async function detachPageFromOwnedServer(page) {
// 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) {
@ -129,7 +140,7 @@ try {
const errors = [];
page.on("pageerror", (error) => errors.push(String(error)));
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.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
// même origin et permet de vérifier les brouillons session après redémarrage.
await detachPageFromOwnedServer(page);
await stopServer(server);
server = startServer(library, runtime, state, assignedPort);
const restartedForB = await ready(server);
assert.equal(restartedForB.origin, session.origin);
await login(page, restartedForB);
const restartedForB = await assertServerReady(server, session.origin);
await resumeAutomaticSessionAfterOwnedRestart(page, restartedForB, "B");
assert.equal(await page.$eval("#workspace-list", (item) => item.children.length), 1);
assert.equal(await page.$eval(".workspace-card h3", (item) => item.textContent), workspaceATitle);
assert.match(await page.$eval(".workspace-card p", (item) => item.textContent), /Prête à ouvrir/);
@ -332,11 +343,11 @@ try {
await page.click("#home-button");
await page.waitForSelector("#library-home:not([hidden])");
await detachPageFromOwnedServer(page);
await stopServer(server);
server = startServer(library, runtime, state, assignedPort);
const restartedForA = await ready(server);
assert.equal(restartedForA.origin, session.origin);
await login(page, restartedForA);
const restartedForA = await assertServerReady(server, session.origin);
await resumeAutomaticSessionAfterOwnedRestart(page, restartedForA, "C");
assert.equal(await page.$eval("#workspace-list", (item) => item.children.length), 2);
await openWorkspace(page, workspaceAId);
assert.equal(await page.$eval("#investigation-title", (item) => item.textContent),

View file

@ -4,6 +4,7 @@ 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),
PROTOTYPE = new URL("..", import.meta.url);
@ -14,11 +15,10 @@ function ready(server) {
const timer = setTimeout(() => reject(new Error("démarrage expiré")), 8000);
server.stdout.on("data", (chunk) => {
text += chunk;
const url = text.match(/http:\/\/127\.0\.0\.1:(\d+)\//),
code = text.match(/Code de session éphémère : ([^\s]+)/);
if (url && code) {
const url = text.match(/http:\/\/127\.0\.0\.1:(\d+)\//);
if (url) {
clearTimeout(timer);
resolve({ url: url[0], code: code[1] });
resolve({ url: url[0] });
}
});
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-")),
profile = await mkdtemp(join(tmpdir(), "labfy-workbench-firefox-"));
@ -81,7 +73,7 @@ try {
page.on("pageerror", (error) => errors.push(String(error)));
await page.setViewport({ width: 1440, height: 900 });
const start = Date.now();
await login(page, session);
await openAutomaticSession(page, session.url);
await page.waitForSelector(".node");
const metrics = await page.evaluate(() => ({
nodes: document.querySelectorAll(".node").length,

View file

@ -4,6 +4,7 @@ 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);
@ -19,10 +20,9 @@ function ready(server) {
server.stdout.on("data", (chunk) => {
output += chunk;
const url = output.match(/http:\/\/127\.0\.0\.1:\d+\//);
const code = output.match(/Code de session éphémère : ([^\s]+)/);
if (url && code) {
if (url) {
clearTimeout(timer);
resolve({ url: url[0], code: code[1] });
resolve({ url: url[0] });
}
});
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 profile = await mkdtemp(
@ -89,7 +81,7 @@ try {
await page.setViewport({ width: 1440, height: 900 });
const authenticationStarted = performance.now();
await login(page, session);
await openAutomaticSession(page, session.url);
await page.waitForSelector(".node");
const firstRenderMs = Math.round(performance.now() - authenticationStarted);

View file

@ -4,6 +4,7 @@ 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);
@ -16,10 +17,9 @@ function waitForServer(server) {
server.stdout.on("data", (chunk) => {
output += chunk.toString();
const url = output.match(/http:\/\/127\.0\.0\.1:(\d+)\//);
const code = output.match(/Code de session éphémère : ([^\s]+)/);
if (url && code) {
if (url) {
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})`)));
@ -62,11 +62,7 @@ try {
headless: true, userDataDir: profile, args: ["--no-remote"] });
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 900 });
await page.goto(session.url, { waitUntil: "domcontentloaded" });
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 openAutomaticSession(page, session.url);
await page.waitForSelector(`[data-id="evidence:${manifest.eml_id}"]`);
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")
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
cls.thread.start()
status, headers, _ = cls.request_raw(
"POST", "/api/v1/session", {"bootstrap_code": "SPECIMEN-CODE"},
origin=cls.server.origin,
)
if status != 200:
status, headers, _ = cls.request_raw("GET", "/")
if status != 303:
raise RuntimeError("session J6 impossible")
cls.cookie = headers["Set-Cookie"].split(";", 1)[0]
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):
self.assertEqual(self.request_raw(
"POST", "/api/v1/session", {"bootstrap_code": ["SPECIMEN-CODE"]},
origin=self.server.origin)[0], 400)
"POST", "/api/v1/session", {}, origin=self.server.origin)[0], 404)
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("POST", "/api/v1/queue/pause", {},

View file

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

View file

@ -48,7 +48,7 @@ class WorkspaceServer(ThreadingHTTPServer):
block_on_close = False
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,
research_fixture_authority=None, automatic_session=False):
if (not isinstance(session_ttl_seconds, int) or
@ -59,10 +59,10 @@ class WorkspaceServer(ThreadingHTTPServer):
self.bridge = bridge.resolve()
self.authority = f"127.0.0.1:{self.server_port}"
self.origin = f"http://{self.authority}"
self.bootstrap = bootstrap
# CONTRACT: ce mode est activé seulement par le lanceur possédé. Il
# émet un cookie HttpOnly local mais ne relâche jamais Origin/CSRF.
self.automatic_session = automatic_session
# CONTRACT: le poste Web ne possède plus de parcours code. Toute
# première navigation loopback établit seulement un cookie HttpOnly ;
# Host, Origin et CSRF restent exigés pour chaque mutation.
self.automatic_session = True
self.library = library
self.active_workspace_id = None
self.library_generation = (library.snapshot(None)["generation"]
@ -561,13 +561,20 @@ class Handler(BaseHTTPRequestHandler):
"status": "ready", "instance_id": self.server.instance_id,
"config_id": self.server.config_id})
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 path == "/" and self.server.automatic_session:
if path == "/":
self._open_automatic_session()
return
if path in {"/", "/login.js", "/styles.css"}:
self._serve_static("/login.html" if path == "/" else path)
return
self._error(HTTPStatus.UNAUTHORIZED, "session_required",
"Session locale requise")
return
@ -670,7 +677,7 @@ class Handler(BaseHTTPRequestHandler):
return
path = urlsplit(self.path).path
if path == "/api/v1/session":
self._open_session()
self._error(HTTPStatus.NOT_FOUND, "route_unknown", "Route inconnue")
return
if not self._mutation_allowed():
self._error(HTTPStatus.FORBIDDEN, "mutation_rejected",
@ -1118,31 +1125,6 @@ class Handler(BaseHTTPRequestHandler):
except FileNotFoundError:pass
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):
with self.server.session_lock:
self.server.session = secrets.token_urlsafe(32)
@ -1213,12 +1195,10 @@ def main():
return parsed
parser.add_argument("--port", type=port, default=8081)
args = parser.parse_args()
bootstrap = secrets.token_urlsafe(12)
server = WorkspaceServer(("127.0.0.1", args.port), Handler,
workspace=args.workspace, bridge=args.bridge,
bootstrap=bootstrap)
bootstrap="")
print(f"Labfy J6 : {server.origin}/", flush=True)
print(f"Code de session éphémère : {bootstrap}", flush=True)
try:
server.serve_forever()
except KeyboardInterrupt: