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_KEYdans son.env), donc elle est un hôte, et son.envrefuse toute clé au jeton : la tour ne dépense jamais, elle fait dépenser des boîtes. - Elle ne monte que des VM. Son
.envforceSANDBOX_BACKEND=exedev: pas de Podman dans une VM dans ce chapitre. Les boîtes de travail restent jetables (une VM par boîte, taguéeplume-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), ettower downla 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 premierssh 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 leurbun 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 lsreste 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 longtemps | oui, tant qu’elle est allumée | oui, seule à en avoir le droit | non, jetable |
| Détient la clé de gestion | oui | oui (son .env) | jamais |
| Accède au moteur | votre clé SSH | sa propre clé, révocable seule | jamais |
| Exécute une phase de l’usine | non (depuis le ch. 22) | jamais | oui, toutes |
| Dépense des jetons | non | jamais (.env sans clé au jeton) | oui, sa clé jetable |
| Monte des boîtes Podman | oui, par défaut | non, VM exe.dev seulement | non |
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_KEYapparaî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 sansownerest 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,syncremonte 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 reapdéclare orpheline toute clésbx-dont l’hôte ne connaît pas la fiche. Avec deux hôtes, unreap --yeslancé chez vous avant unsyncré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 vous | Sur la tour | Ce que sync fait | Pourquoi |
|---|---|---|---|
| absente | ouverte | copie, marquée owner: plume-tower | la tour fait foi sur ce qu’elle a monté |
| copie ouverte | ouverte, plus avancée (pid, url) | la copie est mise à jour | même raison |
| copie fermée | ouverte | la fiche fermée remonte à la tour | une fiche fermée l’emporte toujours |
à vous (sans owner) | homonyme | rien : garde | une fiche à vous n’est jamais écrasée |
| n’importe | *.key | jamais copiée | la 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_usddans sa fiche à chaque phase qu’elle exécute. Synchronisez avant de décider (unteardownsur une copie périmée relève la dépense correcte, mais vous fait rater lerun.logqu’unobservefrais 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.