Annexe — L'usine de demain Chapitre B1 / 42

L'usine entière dans le cloud : la tour de contrôle exe.dev

Le poste de pilotage quitte votre machine : une VM exe.dev persistante, la tour de contrôle, détient seule le droit de vivre longtemps et les deux credentials du chapitre 23, monte et démonte des boîtes sans qu'aucune machine à vous reste allumée — et la fiche de run voyage entre la tour et votre poste, un propriétaire à la fois.

Au chapitre 25, vous avez lancé un best-of-N à trois bras, lu la moisson, gardé le vainqueur. Rappelez-vous ce qu’il a fallu pour cela : votre machine allumée pendant le fan-out, encore allumée pendant la moisson, et un teardown tapé par vous, chez vous. Les boîtes tournaient sans vous, mais le poste de pilotage, lui, c’était toujours votre portable, avec .env, la clé de gestion et l’accès au moteur dedans. Fermez le capot au mauvais moment, et un mount s’arrête à mi-chemin. Ce chapitre ouvre une annexe de prospective outillée : où va l’usine ? Première réponse, la plus concrète : le poste de pilotage lui-même quitte votre machine. À la fin du chapitre, vous saurez monter une tour de contrôle, une VM exe.dev persistante qui est un hôte et jamais une boîte, qui détient les deux credentials du chapitre 23 et fait tourner just sandbox mount, execute et teardown pendant que tout ce qui vous appartient est éteint. Vous saurez aussi faire voyager une fiche de run entre cette tour et votre poste sans jamais avoir deux propriétaires pour une même boîte. Prérequis assumé : un compte exe.dev (Personal, 20 $ par mois au moment d’écrire). Le chapitre se lit à sec si vous n’en avez pas, et sa gate déterministe tourne sans compte. La pièce du jour est adws/sandbox_tower.py, avec son module tower.just monté dans lifecycle.just.

Le poste de pilotage quitte votre machine

L’idée en une phrase

La tour de contrôle est un hôte de plus, une VM exe.dev persistante, tenue par les mêmes pièces que votre poste (sandbox_lifecycle.py, sandbox_keys.py, sandbox_bestof.py) et par le même port « boîte », qui a seule le droit de vivre longtemps et de détenir les credentials d’orchestration. Elle vit entièrement du côté code déterministe de la couture : aucune phase de l’usine, aucun agent, aucune clé jetable ne tourne sur la tour, et ce qu’elle commande, une boîte le fait.

Points clés

  • Un hôte, pas une boîte. Le chapitre 23 a fixé la règle « une boîte qui ne peut pas s’authentifier ne peut pas monter de boîte » : c’est la frontière des credentials qui empêche l’emboîtement. La tour est l’exception nommée : elle peut s’authentifier (sa propre clé SSH vers le moteur, OPENROUTER_PROVISIONING_KEY dans son .env), donc elle est un hôte, et son .env refuse toute clé au jeton : la tour ne dépense jamais, elle fait dépenser des boîtes.
  • Elle ne monte que des VM. Son .env force SANDBOX_BACKEND=exedev : pas de Podman dans une VM dans ce chapitre. Les boîtes de travail restent jetables (une VM par boîte, taguée plume-factory), et la tour porte un autre tag, plume-factory-tower, pour ne jamais être prise pour un bras.
  • Une clé qui lui appartient. La clé SSH de la tour est générée sur la tour et n’en sort jamais. Vous en enregistrez la partie publique sur votre compte (ssh exe.dev ssh-key add), et tower down la retire avant de détruire la VM. La tour ne peut pas s’inviter elle-même, et sa confiance dans le moteur est ancrée sur la vôtre : elle n’accepte que la clé d’hôte que votre poste a déjà vérifiée au premier ssh exe.dev.
  • Coût honnête. L’abonnement Personal est un pool de 2 vCPU / 8 Go partagé entre toutes vos VM (jusqu’à 50, 25 Go de disque chacune par défaut, 100 Go groupés). La tour y vit en permanence : elle ne facture pas à l’heure, elle occupe. Tenue petite (--memory 2GB), au repos elle pèse quelques centaines de mégaoctets : ce sont les boîtes, avec leur bun install, qui consomment. Un best-of-N à cinq bras plus la tour partagent la même puissance, et le palier Team (25 $ par utilisateur) élargit le pool par personne.
  • Les VM oubliées. Une VM qui n’a plus de fiche nulle part occupe votre pool et, si un agent y tourne encore, dépense la clé de son run. ssh exe.dev ls reste le seul inventaire complet, et la tour, elle, refuse de se détruire tant qu’elle tient des fiches ouvertes.

Exemple concret

Vous montez la tour un soir : just sandbox tower up --memory 2GB. Une à deux minutes, zéro token : la VM est créée, le repo chargé par le même filtre que fill, le .env de la tour écrit par stdin (deux lignes, jamais dans un argument de commande), bun et just installés dans son HOME par le script de provision du chapitre 22, sa clé SSH générée puis enregistrée, et un préflight qui refuse de la déclarer prête tant que ssh exe.dev whoami n’a pas répondu depuis la tour. Puis just sandbox tower run mount plume --limit 2 : la tour crée une boîte, la remplit, la provisionne, prouve la porte et la clé jetable par le scout habituel, pour moins d’un centime, et vous rend la main. Vous lancez l’execute de la boîte par la tour, avec la demande « Ajoute un export Markdown », et vous fermez le portable. Le lendemain, un observe par la tour (just sandbox tower run observe plume-…) vous dit que le run est terminé, que la trace est dans la boîte, et que Plume répond sur son URL exe.dev. Rien chez vous n’est resté allumé, et rien sur la tour n’a coûté un jeton.

Ce que la tour fait, ce qu’elle ne fait jamais

Votre poste (ch. 22-25)La tour (aujourd’hui)Une boîte
Vit longtempsoui, tant qu’elle est alluméeoui, seule à en avoir le droitnon, jetable
Détient la clé de gestionouioui (son .env)jamais
Accède au moteurvotre clé SSHsa propre clé, révocable seulejamais
Exécute une phase de l’usinenon (depuis le ch. 22)jamaisoui, toutes
Dépense des jetonsnonjamais (.env sans clé au jeton)oui, sa clé jetable
Monte des boîtes Podmanoui, par défautnon, VM exe.dev seulementnon

Commande — la surface just sandbox tower

Une seule version suffit : la tour ne touche aucun harnais. Tout ce qu’elle exécute passe par sandbox_lifecycle.py ou sandbox_bestof.py, exactement comme chez vous, et le port du chapitre 7 reste dans la boîte. Collez ces lignes une par une. run accepte les verbes des chapitres 22 à 25 et rien d’autre.

# monter (ou rafraîchir : repo, .env, provision, clé, préflight) — ~1-2 min, zéro token
just sandbox tower up --memory 2GB
# une phase, exécutée SUR la tour, lue en direct — les mêmes mots qu'en local
just sandbox tower run mount plume --limit 2
just sandbox tower run execute plume-<la-date>-<6 hex> "Ajoute un export Markdown"
just sandbox tower run observe plume-<la-date>-<6 hex>
# un shell sur la tour : `cd app` puis `just sandbox …`, comme chez vous
just sandbox tower shell
# la tour est-elle encore prête ? (zéro token)
just sandbox tower preflight

Deux nuances. run est synchrone : ce qui tourne longtemps sans vous, c’est le travail détaché dans la boîte (execute, chapitre 22). Un mount ou un fanout de quelques minutes veut votre connexion ouverte pendant ce temps, ou un tower shell et un nohup. Et rien ici n’est déclenché sans vous, ni webhook, ni cron, ni « surveille la branche et lance » : la tour rend possible l’absence, elle n’automatise pas la décision. Ce périmètre est délibéré.

Piège courant : « la tour a la clé de gestion, autant y mettre aussi la clé au jeton et y lancer un ADW quand une boîte serait trop lourde » est inexact. Une tour qui exécute une phase devient une boîte qui peut monter des boîtes, et c’est exactement l’emboîtement que la frontière du chapitre 23 interdit. Son préflight rend rouge dès qu’un OPENROUTER_API_KEY apparaît dans son .env : ce n’est pas une contrainte d’outillage, c’est la loi de l’usine.


Une fiche de run qui voyage entre la tour et votre poste

L’idée en une phrase

La fiche de run (adws/adw_data/sandbox/<run>.json, chapitre 22) vit chez l’hôte qui a monté la boîte, et tower sync en rapporte chez vous une copie marquée owner, jamais la clé jetable, selon une règle de fusion écrite en code, sans service tiers : une fiche fermée l’emporte toujours, sinon la copie de la tour fait foi, et une fiche à vous n’est jamais écrasée. La couture ne bouge pas : le code possède la fiche et sa propagation, l’agent n’en voit jamais une.

Points clés

  • Ce qui voyage, ce qui reste. L’archive que la tour envoie contient ses fiches, ses lots de best-of-N, ses preuves (<run>-artifacts/) et ses relevés de moisson, et exclut *.key à la source : la clé jetable d’un run reste sur l’hôte qui l’a frappée. Chez vous, une copie s’observe (just sandbox observe), se moissonne (harvest) et même se démonte (teardown) : votre poste a la clé de gestion et l’accès au moteur, la révocation se fait par le hash de la fiche, jamais par le fichier de clé.
  • Un propriétaire à la fois. La copie porte "owner": "plume-tower", et une fiche sans owner est la vôtre. Le code n’écrase jamais une fiche à vous par une homonyme venue de la tour, et n’écrase jamais une fiche fermée par une ouverte : si vous avez démonté une boîte depuis votre poste, sync remonte la fiche fermée à la tour pour qu’elle l’apprenne. Deux hôtes peuvent lire, un seul état final gagne, et c’est toujours le plus avancé.
  • Le filet du chapitre 23 raisonne par hôte. just sandbox reap déclare orpheline toute clé sbx- dont l’hôte ne connaît pas la fiche. Avec deux hôtes, un reap --yes lancé chez vous avant un sync révoquerait la clé d’une boîte vivante montée par la tour. La règle tient en quatre mots : sync avant reap, sur chaque hôte.
  • Coût. Un sync, c’est un tar de quelques kilooctets par SSH : une seconde, zéro token. Une fiche qui voyage ne fait tourner aucun agent.

Exemple concret

La tour a monté plume-20260907-3f9a1c hier soir et le run est terminé. Ce matin, chez vous : just sandbox tower sync imprime miroir : plume-20260907-3f9a1c pid 4127 sur plume-tower, et just sandbox list montre la boîte, moteur exedev, montée ailleurs. Vous lisez de l’extérieur avec just sandbox observe plume-20260907-3f9a1c, en SSH direct vers la boîte puisque votre clé est sur le compte, puis vous décidez : just sandbox teardown plume-20260907-3f9a1c. La dépense est relevée, run.patch et factory.db arrivent dans votre adws/adw_data/sandbox/, la clé est révoquée par son hash, la VM détruite, la fiche fermée chez vous. Un dernier just sandbox tower sync : remonte : plume-20260907-3f9a1c fermee chez vous — la tour l'apprend. Sur la tour, list montre désormais fermee, et son propre reap ne trouvera rien à moissonner. Coût du matin : la preuve, quelques centaines de kilooctets, zéro jeton.

La règle de fusion, cas par cas

Chez vousSur la tourCe que sync faitPourquoi
absenteouvertecopie, marquée owner: plume-towerla tour fait foi sur ce qu’elle a monté
copie ouverteouverte, plus avancée (pid, url)la copie est mise à jourmême raison
copie ferméeouvertela fiche fermée remonte à la tourune fiche fermée l’emporte toujours
à vous (sans owner)homonymerien : gardeune fiche à vous n’est jamais écrasée
n’importe*.keyjamais copiéela clé jetable reste sur l’hôte qui l’a frappée

Script — la règle, en une fonction pure

Le cœur de la pièce tient en quelques lignes, testées à sec par la gate. Tout le reste est du transport : un tar par le port « boîte », un filtre qui refuse ce qui n’est pas une fiche.

def merge_records(local: dict | None, remote: dict, owner: str) -> tuple[dict | None, dict | None]:
    """(ce qu'on ecrit chez vous, ce qu'on remonte a la tour).
    Une fiche fermee l'emporte toujours ; sinon la copie de la tour fait foi.
    Une fiche qui n'a pas d'owner chez vous est A VOUS : jamais ecrasee."""
    if local is not None and not local.get("owner"):
        return None, None                                 # votre propre boite, homonyme : intouchable
    if local is not None and local.get("closed_at") and not remote.get("closed_at"):
        return None, local                                # vous l'avez fermee : la tour doit l'apprendre
    mirror = dict(remote)
    mirror["owner"] = owner
    return mirror, None

Piège courant : « puisque la copie est chez moi, la tour n’a plus rien à en faire, autant ne synchroniser qu’une fois » est inexact. Une copie est une photographie : la tour continue d’écrire pid, url, spend_usd dans sa fiche à chaque phase qu’elle exécute. Synchronisez avant de décider (un teardown sur une copie périmée relève la dépense correcte, mais vous fait rater le run.log qu’un observe frais vous aurait montré), et synchronisez après avoir fermé, pour que la tour l’apprenne.


Fil rouge — la pièce posée aujourd’hui

Sur le plan de l’usine, la zone just/sandbox/ reçoit un troisième module, tower.just, monté par lifecycle.just à côté de keys.just et orch.just, avec sa logique dans adws/sandbox_tower.py, un hôte de plus derrière le port « boîte » du chapitre 22, qui n’invente aucune phase : il déplace le poste de pilotage. La loi ne bouge pas : l’agent propose, le code dispose, et aujourd’hui le code dispose aussi de l’endroit d’où l’on dispose. La couture reste dans la boîte, là où le runner du chapitre 8 contient l’agent. La tour n’en voit que l’enveloppe qui en sort, la fiche, et la propagation de cette fiche est elle-même une règle écrite (fermée l’emporte, la tour fait foi, la vôtre est intouchable). Ce qui traverse la frontière : le repo dans un sens, les fiches et les preuves dans l’autre, jamais une clé jetable, jamais la clé de gestion hors des deux hôtes qui ont le droit de la tenir. À l’usage : l’abonnement, une à deux minutes et zéro token pour monter la tour, une seconde par sync, le prix habituel d’un run pour le travail. En retour, la capacité que même le chapitre 22 ne donnait pas : une usine qui tourne pendant que tout ce qui vous appartient est éteint.


Travaux pratiques — la pièce du jour

Trois fichiers à poser dans le repo compagnon plume-factory, qui devient, chapitre après chapitre, votre usine logicielle agentique : la tour, son module just, et le lifecycle.just qui le monte (une ligne). Rien de ce qui a été posé aux chapitres 22 à 27 ne change, et les trois scripts du hors-site sont importés tels quels.

Prérequis : un compte exe.dev (Personal, ~20 $ par mois au moment d’écrire), ssh exe.dev whoami déjà passé une fois depuis votre poste en vérifiant l’empreinte affichée contre celle de la documentation, et OPENROUTER_PROVISIONING_KEY dans votre .env (chapitre 23). Sans compte, la première commande de la gate tourne à sec, le reste se lit.

Pièce — adws/sandbox_tower.py

La tour, côté code déterministe, bibliothèque standard uniquement. up monte ou rafraîchit la VM (repo par le filtre de fill, .env par stdin, provision du chapitre 22, clé SSH générée sur place et enregistrée par vous, préflight). run exécute sur la tour un verbe des chapitres 22 à 25, et rien d’autre. sync applique la règle de fusion. down retire la clé puis détruit, et refuse tant qu’une fiche est ouverte. Tout passe par ExeDevBox : ce fichier n’appelle jamais ssh hors du port.

#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# ///
"""sandbox_tower — la tour de controle : le poste de pilotage du hors-site, dans le cloud.

Depuis le ch. 22, le poste de pilotage est votre machine : c'est elle qui
monte les boites, y lance le travail, les observe et les demonte — et elle
doit rester allumee pendant qu'elle le fait. La tour est une VM exe.dev
PERSISTANTE qui prend ce role : elle detient les deux credentials du ch. 23
(sa propre cle SSH vers le moteur, et OPENROUTER_PROVISIONING_KEY dans son
.env) et fait tourner `just sandbox mount / execute / teardown` sans qu'aucune
machine a vous reste allumee.

Deux lois, tenues par ce fichier :
  - la tour est un HOTE, jamais une boite : aucune phase de l'usine n'y
    tourne, aucun agent, aucune cle jetable ; son .env ne contient que le
    moteur (exedev) et la cle de gestion. Ce qu'elle fait, une boite le fait.
  - une FICHE, un proprietaire a la fois : la fiche d'une boite vit chez
    l'hote qui l'a montee. `sync` en rapatrie une COPIE chez vous (marquee
    `owner`), jamais la cle jetable ; une fiche fermee l'emporte toujours,
    et remonte a la tour pour qu'elle l'apprenne.

Tout passe par le port « boite » du ch. 22 (ExeDevBox : plan de controle
`ssh exe.dev <verbe>`, plan de donnees `ssh <vm> "<commande>"`) — rien ici
n'appelle `podman`, et `ssh` n'est jamais appele hors du port. Bibliotheque
standard uniquement.

    uv run adws/sandbox_tower.py up [--memory 2GB …]   monter ou rafraichir la tour
    uv run adws/sandbox_tower.py preflight             la tour est-elle prete ? (zero token)
    uv run adws/sandbox_tower.py run <verbe> [args…]   une phase, executee SUR la tour
    uv run adws/sandbox_tower.py sync                  les fiches de la tour, copiees chez vous
    uv run adws/sandbox_tower.py shell                 un shell sur la tour
    uv run adws/sandbox_tower.py down [--yes]          la cle retiree, la VM detruite
    uv run adws/sandbox_tower.py --selftest            la gate a sec, zero reseau
"""
from __future__ import annotations

import argparse
import io
import json
import re
import shlex
import subprocess
import sys
import tarfile
import time
from pathlib import Path

# Les pieces des ch. 22-23, importees telles quelles (meme dossier : uv place
# adws/ en tete du chemin d'import). La tour n'invente rien : elle est un
# hote de plus, pilote par les memes pieces.
import sandbox_box as boxes
import sandbox_keys as keys
import sandbox_lifecycle as life
from sandbox_keys import die, now

TOWER_NAME = "plume-tower"                    # le nom de la VM — et donc de l'hote plume-tower.exe.xyz
TOWER_TAG = "plume-factory-tower"             # distinct du tag des boites : une tour n'est pas un bras
TOWER_FILE = Path("adws/adw_data/tower.json") # la fiche de la tour, hors du dossier des fiches de run
KEY_COMMENT = "plume-tower"                   # le nom de la cle SSH de la tour sur votre compte
DATA_DIR = "app/adws/adw_data"                # sur la tour, depuis le HOME
PORTABLE_VERBS = {                            # ce que `run` accepte d'executer SUR la tour
    "mount": "sandbox_lifecycle.py", "create": "sandbox_lifecycle.py", "fill": "sandbox_lifecycle.py",
    "setup": "sandbox_lifecycle.py", "execute": "sandbox_lifecycle.py", "observe": "sandbox_lifecycle.py",
    "teardown": "sandbox_lifecycle.py", "list": "sandbox_lifecycle.py",
    "fanout": "sandbox_bestof.py", "harvest": "sandbox_bestof.py", "discard": "sandbox_bestof.py",
}

# La cle SSH de la tour : generee SUR la tour, jamais lue par personne. Le
# script rend la ligne publique ; c'est vous, depuis votre poste, qui
# l'enregistrez sur le compte — la tour ne peut pas s'inviter elle-meme.
# $1 = l'empreinte de la cle d'hote exe.dev que VOTRE poste a deja verifiee :
# la tour ne fait confiance qu'a ce que vous avez deja accepte, et couvre
# *.exe.xyz du meme coup (toutes les VM presentent la cle d'hote du moteur).
KEY_SCRIPT = r"""
set -euo pipefail
mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh"
if [ ! -f "$HOME/.ssh/id_exe" ]; then
  ssh-keygen -q -t ed25519 -N "" -C "plume-tower" -f "$HOME/.ssh/id_exe"
fi
if ! grep -qs "IdentityFile ~/.ssh/id_exe" "$HOME/.ssh/config"; then
  printf 'Host exe.dev *.exe.xyz\n  IdentitiesOnly yes\n  IdentityFile ~/.ssh/id_exe\n' >> "$HOME/.ssh/config"
  chmod 600 "$HOME/.ssh/config"
fi
if ! ssh-keygen -F exe.dev -f "$HOME/.ssh/known_hosts" >/dev/null 2>&1; then
  ssh-keyscan -T 10 exe.dev 2>/dev/null | while read -r host type key; do
    fp="$(printf '%s %s\n' "$type" "$key" | ssh-keygen -lf - | awk '{print $2}')"
    if [ "$fp" = "$1" ]; then printf 'exe.dev,*.exe.xyz %s %s\n' "$type" "$key" >> "$HOME/.ssh/known_hosts"; fi
  done
fi
ssh-keygen -F exe.dev -f "$HOME/.ssh/known_hosts" >/dev/null 2>&1 || { echo "KO  aucune cle d'hote exe.dev ne correspond a l'empreinte $1"; exit 1; }
cat "$HOME/.ssh/id_exe.pub"
"""

# Le preflight de la tour : ce que le ch. 21 verifie, plus ce qui n'existe
# que pour une tour — l'acces au moteur avec SA cle, et un .env qui ne
# contient que ce qu'une tour a le droit de detenir.
PREFLIGHT_SCRIPT = r"""
set -u
export PATH="$HOME/.bun/bin:$HOME/.local/bin:/usr/local/bin:$PATH"
cd "$HOME/app" || { echo "KO  ~/app absent — tower up n a pas charge le repo"; exit 1; }
for t in uv just git ssh sqlite3; do
  command -v "$t" >/dev/null 2>&1 && echo "ok  $t" || { echo "KO  $t introuvable"; exit 1; }
done
grep -qs '^SANDBOX_BACKEND=exedev$' .env && echo "ok  .env : SANDBOX_BACKEND=exedev" || { echo "KO  .env : la tour ne monte que des VM exe.dev"; exit 1; }
grep -qs '^OPENROUTER_PROVISIONING_KEY=.' .env && echo "ok  .env : cle de gestion presente" || { echo "KO  .env : OPENROUTER_PROVISIONING_KEY absente"; exit 1; }
grep -qs '^OPENROUTER_API_KEY=' .env && { echo "KO  .env : une cle au jeton sur la tour — la tour ne depense jamais"; exit 1; } || echo "ok  .env : aucune cle au jeton (la tour ne depense pas)"
who="$(ssh -o BatchMode=yes -o ConnectTimeout=15 exe.dev whoami 2>/dev/null | head -1)"
[ -n "$who" ] && echo "ok  moteur : ssh exe.dev whoami repond ($who)" || { echo "KO  moteur : ssh exe.dev refuse la cle de la tour — est-elle enregistree ? (ssh exe.dev ssh-key list)"; exit 1; }
uv run adws/sandbox_lifecycle.py --selftest >/dev/null 2>&1 && echo "ok  cycle de vie : gate a sec verte" || { echo "KO  sandbox_lifecycle --selftest rouge sur la tour"; exit 1; }
uv run adws/sandbox_preflight.py >/dev/null 2>&1 && echo "ok  preflight du ch. 21 : hors-site OK (exedev)" || { echo "KO  sandbox_preflight rouge sur la tour — lancez-le depuis tower shell pour lire le detail"; exit 1; }
"""


# ── la fiche de la tour ─────────────────────────────────────────────────────

def tower_record() -> dict:
    """La tour vue par le port : une fiche minimale, comme celle d'une boite."""
    return {"run_id": TOWER_NAME, "backend": "exedev"}


def load_tower() -> dict:
    if not TOWER_FILE.is_file():
        die("aucune tour montee — `just sandbox tower up` d'abord")
    return json.loads(TOWER_FILE.read_text(encoding="utf-8"))


def save_tower(record: dict) -> None:
    TOWER_FILE.parent.mkdir(parents=True, exist_ok=True)
    TOWER_FILE.write_text(json.dumps(record, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")


def tower_env(provisioning_key: str) -> str:
    """Le .env de la tour : le moteur, et la cle de gestion. Rien d'autre —
    surtout pas une cle au jeton : la tour ne depense jamais."""
    return f"SANDBOX_BACKEND=exedev\nOPENROUTER_PROVISIONING_KEY={provisioning_key}\n"


def host_fingerprint() -> str:
    """L'empreinte de la cle d'hote exe.dev telle que VOTRE poste l'a memorisee
    au premier `ssh exe.dev whoami` — le point d'ancrage de la confiance de la tour."""
    proc = boxes._run(["ssh-keygen", "-l", "-F", "exe.dev"], check=False)
    match = re.search(r"SHA256:[A-Za-z0-9+/=]+", proc.stdout or "")
    if not match:
        die("exe.dev n'est pas dans votre known_hosts — lancez `ssh exe.dev whoami` une fois,"
            " en verifiant l'empreinte affichee contre celle de la documentation")
    return match.group(0)


def key_registered(listing: str, public_line: str) -> bool:
    """La cle de la tour est-elle sur le compte ? Par son corps (base64) ou son
    empreinte, selon ce que `ssh-key list` affiche — jamais par son nom seul."""
    parts = public_line.split()
    body = parts[1] if len(parts) >= 2 else public_line.strip()
    return bool(body) and body in listing


# ── up : monter ou rafraichir ───────────────────────────────────────────────

def upload_repo(box: boxes.Box, record: dict) -> None:
    """Le repo, en flux, par le meme filtre que fill (ch. 22) — mais SANS
    detruire ~/app : les fiches de la tour (adw_data/) survivent au rafraichissement."""
    proc = subprocess.Popen(box.argv(record, "mkdir -p app && tar xzf - -C app"), stdin=subprocess.PIPE)
    with tarfile.open(fileobj=proc.stdin, mode="w|gz") as tar:
        tar.add(".", arcname=".", filter=life.keep)
    proc.stdin.close()
    if proc.wait() != 0:
        die("le chargement du repo sur la tour a echoue — la tour est gardee")


def up(flags: list[str]) -> None:
    provisioning = keys.provisioning_key()          # presence verifiee chez vous ; jamais affichee
    fingerprint = host_fingerprint()                # votre confiance, avant la sienne
    box = boxes.ExeDevBox()                         # la tour est toujours une VM : jamais Podman
    record = tower_record()
    # 1. la VM, si elle n'existe pas : persistante, taguee tour, dimensionnee par vos options.
    vm = box.find_vm(TOWER_NAME)
    if vm is None:
        box.control("new", "--name", TOWER_NAME, "--tag", TOWER_TAG, *flags, "--json")
        vm = box.find_vm(TOWER_NAME)
        if vm is None:
            die(f"la VM {TOWER_NAME} n'apparait pas dans `ssh exe.dev ls`")
        print(f"tour   : {TOWER_NAME} creee ({vm.get('https_url') or 'pas d URL'})")
    else:
        print(f"tour   : {TOWER_NAME} existe — rafraichissement")
    record.update({"box": vm.get("ssh_dest") or box.dest(TOWER_NAME), "url": vm.get("https_url"),
                   "created_at": record.get("created_at") or now()})
    save_tower(record)
    # 2. attendre la porte SSH — bornee, comme au ch. 22.
    deadline = time.monotonic() + 90
    while time.monotonic() < deadline:
        if box.ok(record, "true"):
            break
        time.sleep(2)
    else:
        die(f"{TOWER_NAME} n'a jamais repondu en 90 s — la VM est gardee")
    # 3. le repo, puis le .env de la tour — par stdin, jamais dans argv, jamais affiche.
    upload_repo(box, record)
    box.exec(record, "umask 077 && cat > app/.env && chmod 600 app/.env", script=tower_env(provisioning))
    print("repo   : charge dans ~/app ; .env de la tour ecrit (moteur + cle de gestion, 0600)")
    # 4. provision : le MEME script que les boites (ch. 22) — bun et just dans le HOME.
    print("provis : " + box.exec(record, "bash -s", script=life.PROVISION).strip().splitlines()[-1])
    # 5. la cle de la tour : generee sur la tour, enregistree par vous, une seule fois.
    out = box.exec(record, f"bash -s {shlex.quote(fingerprint)}", script=KEY_SCRIPT, check=False)
    public_line = (out or "").strip().splitlines()[-1] if out.strip() else ""
    if "KO " in out or not public_line.startswith("ssh-"):
        print(out.strip()[-400:], file=sys.stderr)
        die("la cle de la tour n'a pas pu etre preparee — la VM est gardee")
    listing = box.control("ssh-key", "list", check=False)
    if key_registered(listing, public_line):
        print("cle    : deja enregistree sur votre compte")
    else:
        verdict = box.control("ssh-key", "add", public_line, check=False).strip().splitlines()
        print(f"cle    : enregistree sur votre compte ({KEY_COMMENT}) — {verdict[-1] if verdict else 'ssh-key list la montre'}")
    record["key_public"] = public_line
    save_tower(record)
    # 6. le preflight de la tour, avant d'y monter quoi que ce soit.
    preflight()
    print(f"\nmontee : {TOWER_NAME}\n  une boite   : just sandbox tower run mount plume --limit 2"
          "\n  les fiches  : just sandbox tower sync\n  un shell    : just sandbox tower shell")


def preflight() -> None:
    record = load_tower()
    box = boxes.for_record(record)
    out = box.exec(record, "bash -s", script=PREFLIGHT_SCRIPT, check=False)
    print("\n".join("     " + line for line in out.strip().splitlines()))
    if "KO " in out:
        die("tour : NON prete")
    print("tour   : prete")


# ── run : une phase, executee sur la tour ───────────────────────────────────

def remote_command(verb: str, args: list[str]) -> str:
    """La commande que la tour executera : le meme script que chez vous, les
    memes arguments, quotes pour SON shell. La tour ne fait rien que vous ne
    puissiez taper dans `tower shell`."""
    if verb not in PORTABLE_VERBS:
        die(f"verbe {verb!r} inconnu — la tour execute : {', '.join(PORTABLE_VERBS)}")
    return ('export PATH="$HOME/.bun/bin:$HOME/.local/bin:$PATH"; cd "$HOME/app" && '
            + shlex.join(["uv", "run", f"adws/{PORTABLE_VERBS[verb]}", verb, *args]))


def run(verb: str, args: list[str]) -> int:
    record = load_tower()
    box = boxes.for_record(record)
    # Pas de capture : vous lisez la phase en direct, comme si vous etiez sur la tour.
    return subprocess.run(box.argv(record, remote_command(verb, args)), stdin=subprocess.DEVNULL).returncode


def shell() -> int:
    record = load_tower()
    box = boxes.for_record(record)
    print(f"tour   : {record['box']} — `cd app` puis `just sandbox …` ; `exit` pour revenir")
    return subprocess.run(box.argv(record, "cd app 2>/dev/null; exec bash -l", tty=True)).returncode


# ── sync : les fiches voyagent ──────────────────────────────────────────────

def merge_records(local: dict | None, remote: dict, owner: str) -> tuple[dict | None, dict | None]:
    """La regle, en une fonction pure : (ce qu'on ecrit chez vous, ce qu'on remonte a la tour).
    Une fiche fermee l'emporte toujours ; sinon la copie de la tour fait foi.
    Une fiche qui n'a pas d'owner chez vous est A VOUS : jamais ecrasee."""
    if local is not None and not local.get("owner"):
        return None, None                                 # votre propre boite, homonyme : intouchable
    if local is not None and local.get("closed_at") and not remote.get("closed_at"):
        return None, local                                # vous l'avez fermee : la tour doit l'apprendre
    mirror = dict(remote)
    mirror["owner"] = owner
    return mirror, None


def safe_member(info: tarfile.TarInfo) -> str | None:
    """Ce qui a le droit de sortir de l'archive de la tour : des fichiers ordinaires,
    sous sandbox/, jamais une cle jetable, jamais un chemin qui remonte."""
    name = info.name.lstrip("./")
    parts = Path(name).parts
    if not info.isfile() or len(parts) < 2 or parts[0] != "sandbox" or ".." in parts:
        return None
    if name.endswith(".key"):
        return None
    return str(Path(*parts[1:]))


def pull_archive(box: boxes.Box, record: dict) -> bytes:
    """Le dossier des fiches de la tour, en un tar — la cle jetable exclue A LA SOURCE."""
    return box.exec(record, f"mkdir -p {DATA_DIR}/sandbox && cd {DATA_DIR} && "
                            "tar czf - --exclude='*.key' sandbox", binary=True)


def sync() -> None:
    record = load_tower()
    box = boxes.for_record(record)
    keys.RUNS_DIR.mkdir(parents=True, exist_ok=True)
    archive = pull_archive(box, record)
    pulled, pushed, kept = [], [], []
    with tarfile.open(fileobj=io.BytesIO(archive), mode="r:gz") as tar:
        for info in tar.getmembers():
            rel = safe_member(info)
            if rel is None:
                continue
            data = tar.extractfile(info).read()
            target = keys.RUNS_DIR / rel
            if not (rel.endswith(".json") and "/" not in rel):
                target.parent.mkdir(parents=True, exist_ok=True)   # preuves, moissons : copiees telles quelles
                target.write_bytes(data)
                continue
            remote = json.loads(data.decode("utf-8"))
            local = json.loads(target.read_text(encoding="utf-8")) if target.is_file() else None
            write, push = merge_records(local, remote, TOWER_NAME)
            if write is not None:
                target.write_text(json.dumps(write, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
                pulled.append(rel)
            elif push is not None:
                box.exec(record, f"cat > {DATA_DIR}/sandbox/{shlex.quote(rel)}",
                         script=json.dumps(push, indent=2, ensure_ascii=False) + "\n")
                pushed.append(rel)
            else:
                kept.append(rel)
    record["synced_at"] = now()
    save_tower(record)
    for rel in pulled:
        r = json.loads((keys.RUNS_DIR / rel).read_text(encoding="utf-8"))
        state = ("lot" if rel.startswith("bestof-") else "fermee" if r.get("closed_at")
                 else "pid " + str(r["pid"]) if r.get("pid") else "montee")
        print(f"miroir : {rel[:-5]:<40} {state:<10} sur {TOWER_NAME}")
    for rel in pushed:
        print(f"remonte: {rel[:-5]:<40} fermee chez vous — la tour l'apprend")
    for rel in kept:
        print(f"garde  : {rel[:-5]:<40} homonyme a vous — la copie de la tour est ignoree")
    print(f"sync   : {len(pulled)} copiee(s), {len(pushed)} remontee(s), {len(kept)} gardee(s)"
          " — `just sandbox list` les montre ; une copie s'observe, se moissonne, se demonte ;"
          " sa cle jetable est restee sur la tour")


# ── down : la cle retiree, la VM detruite ───────────────────────────────────

def open_records(archive: bytes) -> list[str]:
    """Les fiches encore ouvertes sur la tour — une tour ne se detruit pas avec
    des boites dont elle est la seule a tenir la fiche."""
    found = []
    with tarfile.open(fileobj=io.BytesIO(archive), mode="r:gz") as tar:
        for info in tar.getmembers():
            rel = safe_member(info)
            if rel and rel.endswith(".json") and "/" not in rel and not rel.startswith("bestof-"):
                r = json.loads(tar.extractfile(info).read().decode("utf-8"))
                if not r.get("closed_at"):
                    found.append(r["run_id"])
    return found


def down(apply: bool) -> None:
    record = load_tower()
    box = boxes.for_record(record)
    if box.exists(TOWER_NAME):
        pending = open_records(pull_archive(box, record))
        if pending:
            die(f"{len(pending)} fiche(s) encore ouverte(s) sur la tour : {', '.join(pending)} —"
                " demontez-les d'abord (just sandbox tower run teardown <run>)")
    if not apply:
        print(f"a sec — rien detruit. La tour {TOWER_NAME} et sa cle seraient retirees : --yes pour agir")
        return
    if record.get("key_public"):
        box.control("ssh-key", "remove", record["key_public"], check=False)
        print("cle    : retiree de votre compte — la tour ne peut plus rien monter")
    if box.exists(TOWER_NAME):
        box.destroy(record)
        print(f"tour   : {TOWER_NAME} detruite")
    record["closed_at"] = now()
    save_tower(record)
    print(f"fiche  : {TOWER_FILE} fermee ; vos copies de fiches restent dans {keys.RUNS_DIR}")


# ── la gate a sec ───────────────────────────────────────────────────────────

def selftest() -> int:
    """Zero reseau, zero VM, zero token : le .env de la tour, la regle de fusion,
    le filtre de l'archive, la commande distante, la detection de cle."""
    ok = tower_env("sk-or-v1-abc") == "SANDBOX_BACKEND=exedev\nOPENROUTER_PROVISIONING_KEY=sk-or-v1-abc\n"
    remote = {"run_id": "plume-1", "backend": "exedev", "box": "plume-1.exe.xyz", "closed_at": None}
    write, push = merge_records(None, remote, "plume-tower")
    ok &= write is not None and write["owner"] == "plume-tower" and push is None
    write, push = merge_records({"run_id": "plume-1", "backend": "exedev"}, remote, "plume-tower")
    ok &= write is None and push is None                                   # la votre : intouchable
    closed = {"run_id": "plume-1", "owner": "plume-tower", "closed_at": now()}
    write, push = merge_records(closed, remote, "plume-tower")
    ok &= write is None and push is closed                                 # fermee chez vous : remonte
    write, push = merge_records({"run_id": "plume-1", "owner": "plume-tower"}, dict(remote, pid=42), "plume-tower")
    ok &= write is not None and write["pid"] == 42                         # ouverte : la tour fait foi
    def member(name: str, isfile: bool = True) -> tarfile.TarInfo:
        info = tarfile.TarInfo(name)
        info.type = tarfile.REGTYPE if isfile else tarfile.DIRTYPE
        return info
    ok &= safe_member(member("sandbox/plume-1.json")) == "plume-1.json"
    ok &= safe_member(member("sandbox/plume-1-artifacts/run.patch")) == "plume-1-artifacts/run.patch"
    ok &= safe_member(member("sandbox/plume-1.key")) is None
    ok &= safe_member(member("sandbox/../.env")) is None
    ok &= safe_member(member("other/x.json")) is None
    ok &= safe_member(member("sandbox", isfile=False)) is None
    cmd = remote_command("mount", ["plume", "--limit", "2"])
    ok &= cmd.endswith("uv run adws/sandbox_lifecycle.py mount plume --limit 2") and 'cd "$HOME/app"' in cmd
    ok &= "'Ajoute un compteur'" in remote_command("execute", ["plume-1", "Ajoute un compteur"])
    ok &= remote_command("harvest", ["lot-1"]).endswith("sandbox_bestof.py harvest lot-1")
    pub = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample plume-tower"
    ok &= key_registered("plume-tower  SHA256:xyz  ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample", pub)
    ok &= not key_registered("my-laptop  ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOther", pub)
    print(f"sandbox_tower {'OK' if ok else 'KO'} — .env de la tour sans cle au jeton, fusion des fiches"
          " (fermee l'emporte, la tour fait foi, la votre intouchable), archive sans cle ni remontee,"
          " verbes bornes, cle reconnue par son corps")
    return 0 if ok else 1


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="la tour de controle du hors-site")
    parser.add_argument("--selftest", action="store_true", help="la gate a sec")
    sub = parser.add_subparsers(dest="verb")
    sub.add_parser("up", help="les options qui suivent (--memory 2GB, --cpu 1, --disk 20GB…) vont a `ssh exe.dev new`")
    sub.add_parser("preflight")
    p = sub.add_parser("run"); p.add_argument("phase"); p.add_argument("args", nargs=argparse.REMAINDER)
    sub.add_parser("sync")
    sub.add_parser("shell")
    p = sub.add_parser("down"); p.add_argument("--yes", action="store_true")
    # `up` laisse passer les options du moteur telles quelles (--memory 2GB…) :
    # parse_known_args les rend sans les interpreter.
    args, engine_flags = parser.parse_known_args()
    if args.selftest:
        raise SystemExit(selftest())
    if args.verb == "up":
        up(engine_flags)
    elif args.verb == "preflight":
        preflight()
    elif args.verb == "run":
        raise SystemExit(run(args.phase, args.args))
    elif args.verb == "sync":
        sync()
    elif args.verb == "shell":
        raise SystemExit(shell())
    elif args.verb == "down":
        down(args.yes)
    else:
        parser.print_help()

Pièce — just/sandbox/tower.just

Le module de la tour, monté par lifecycle.just comme un sous-module (mod), et non importé comme keys.just et orch.just : c’est ce qui donne la forme just sandbox tower up. Un module n’hérite de rien, donc les réglages sont redéclarés, comme au chapitre 22. Une ligne par recette, aucune logique ici.

# just/sandbox/tower.just — la tour de controle du hors-site (B1).
# MONTE par lifecycle.just (`mod tower`) : un module n'herite de rien — reglages redeclares,
# working-directory remonte de deux crans. Une ligne par recette ; la logique vit dans
# adws/sandbox_tower.py. Rien ici ne lit une cle ni n'execute une phase sur ce poste.
set working-directory := '../..'
set positional-arguments
set dotenv-load
set windows-shell := ["C:/Program Files/Git/bin/bash.exe", "-cu"]

# liste les commandes de la tour
default:
    @just --list sandbox tower

# monter ou rafraichir la tour : VM persistante, repo, .env (moteur + cle de gestion), provision, cle SSH, preflight
up *FLAGS:
    uv run adws/sandbox_tower.py up "$@"

# la tour est-elle prete ? acces au moteur avec SA cle, .env sans cle au jeton, gates a sec — zero token
preflight:
    uv run adws/sandbox_tower.py preflight

# une phase executee SUR la tour, lue en direct : just sandbox tower run mount plume --limit 2
run VERB *ARGS:
    uv run adws/sandbox_tower.py run "$@"

# les fiches de la tour, copiees chez vous (owner: plume-tower) ; une fiche fermee chez vous remonte
sync:
    uv run adws/sandbox_tower.py sync

# un shell sur la tour — `cd app` puis `just sandbox …`
shell:
    uv run adws/sandbox_tower.py shell

# la cle de la tour retiree du compte, puis la VM detruite — a sec sans --yes, refuse si une fiche est ouverte
down *FLAGS:
    uv run adws/sandbox_tower.py down "$@"

Pièce — just/sandbox/lifecycle.just

Cette version remplace celle du chapitre 24. Une seule ligne s’ajoute, le montage du module tower, et rien d’autre ne bouge : vos six phases, egress, vos recettes de clés et vos orchestrateurs tournent tels quels.

# just/sandbox/lifecycle.just — le hors-site : le cycle de vie d'une boite (ch. 22).
# Un module n'herite de rien : reglages redeclares, working-directory remonte de deux crans.
set working-directory := '../..'
set positional-arguments
set dotenv-load
set windows-shell := ["C:/Program Files/Git/bin/bash.exe", "-cu"]

# la frontiere des credentials : mint, spend, revoke, keys, reap (ch. 23)
import 'keys.just'

# les orchestrateurs : cmd, delegate, attach, shell (ch. 24)
import 'orch.just'

# la tour de controle : just sandbox tower up, run, sync, shell, down (B1)
mod tower 'tower.just'

# liste les commandes du hors-site
default:
    @just --list sandbox

# le preflight du hors-site (ch. 21) : zero token, quelques secondes
preflight:
    uv run adws/sandbox_preflight.py

# l'image de base des boites Podman (Containerfile, ch. 21) : une fois, ~2 min, reseau ouvert
image:
    podman build -t plume-node .

# la chaine create → fill → setup → observe — jamais teardown : just sandbox mount plume [--limit 5] [--memory 4g]
mount NAME *FLAGS:
    uv run adws/sandbox_lifecycle.py mount "$@"

# phase 1 — la fiche, la boite (reseau interne, porte, conteneur), puis la cle jetable (hote seulement)
create NAME *FLAGS:
    uv run adws/sandbox_lifecycle.py create "$@"

# phase 2 — le repo dans la boite, un commit de reference, la cle jetable par stdin
fill RUN:
    uv run adws/sandbox_lifecycle.py fill "$1"

# phase 3 — provision, sentinelle, gate (a sec puis un scout) : just sandbox setup <run> [--config adws/adw_config/eco.config.yaml]
setup RUN *ARGS:
    uv run adws/sandbox_lifecycle.py setup "$@"

# phase 4 — le travail, detache : just sandbox execute <run> "<demande>" [--config …] [--adw adw_plan]
execute RUN PROMPT *ARGS:
    uv run adws/sandbox_lifecycle.py execute "$@"

# phase 5 — lire de l'exterieur : run.log, la table runs, Plume servie par la porte
observe RUN:
    uv run adws/sandbox_lifecycle.py observe "$1"

# phase 6 — depense, preuve, cle revoquee, boite detruite : une decision, jamais enchainee
teardown RUN:
    uv run adws/sandbox_lifecycle.py teardown "$1"

# les fiches connues : montees, en cours, fermees — avec moteur, cle et depense
list:
    uv run adws/sandbox_lifecycle.py list

# le journal d'egress d'une boite : ce que la porte a laisse passer, ce qu'elle a refuse (Podman)
egress RUN:
    podman logs --tail 40 plume-gate-"$1"

La gate du TP

Cinq commandes, une par ligne, depuis la racine de plume-factory. La première tourne à sec, les quatre suivantes demandent un compte exe.dev et la clé de gestion dans votre .env :

uv run adws/sandbox_tower.py --selftest
just sandbox tower up --memory 2GB
just sandbox tower run mount plume --limit 2
just sandbox tower sync
just sandbox list

Attendu : sandbox_tower OK — .env de la tour sans cle au jeton, fusion des fiches (fermee l'emporte, la tour fait foi, la votre intouchable), archive sans cle ni remontee, verbes bornes, cle reconnue par son corps (zéro réseau, zéro token). La deuxième crée la tour, charge le repo, provisionne, enregistre sa clé, puis imprime le préflight ligne à ligne jusqu’à tour : prete, en une à deux minutes et zéro token. La troisième est le mount du chapitre 23, exécuté sur la tour et lu en direct : la boîte, la clé jetable plafonnée à 2 $, la gate, le scout, en environ une minute et moins d’un centime. La quatrième imprime une ligne miroir : plume-<la-date>-<6 hex> montee sur plume-tower, et la cinquième montre cette boîte dans votre liste, moteur exedev, sans clé jetable chez vous. Pour aller au bout : just sandbox teardown plume-<la-date>-<6 hex> depuis votre poste, puis un dernier just sandbox tower sync qui remonte la fiche fermée. La tour, elle, reste, c’est son rôle, et just sandbox tower down --yes la retire quand vous n’en voulez plus. Variante éco : rien à changer, la gate ne coûte que le scout, l’abonnement est le vrai prix.


Quiz — teste tes connaissances
Annexe — L'usine de demain 7 questions Objectif : 5/7 minimum
0/7
bonnes reponses
Objectif non atteint (minimum 5/7 requis).
Remonte relire la fiche memo en pretant attention aux points manques, puis cliquer sur « Recommencer » pour retenter.