Podman & le cycle de vie d'un sandbox
Votre usine quitte votre poste sans quitter votre machine : une boîte Podman fermée — conteneur, réseau interne, porte à liste d'hôtes — montée en une seconde, six phases déterministes pour la remplir, la vérifier, y travailler, l'observer et la détruire. La pièce du jour est le port « boîte » et le cycle de vie complet ; exe.dev y passe par le même port.
Hier, votre préflight a rendu hors-site : OK (podman) et l’image plume-node est construite :
l’outillage est là, le .gitignore est étanche, la clé est présente sans jamais s’afficher.
Mais l’usine tourne toujours dans votre terminal, et le chapitre 21 a nommé le manque : une
boîte jetable, fermée, bornée par ses clés, que vous ne possédez pas encore. Aujourd’hui, vous
allez en monter une. À la fin de ce chapitre, vous saurez créer une boîte en une commande (un
réseau sans route vers l’extérieur, une porte qui n’ouvre que vers une poignée d’hôtes, un conteneur
sans root ni capacités), y charger l’usine entière, prouver qu’elle est saine et fermée
avant d’y dépenser un token, y lancer un SDLC détaché, le lire de l’extérieur, puis tout
détruire sans rien laisser derrière. La pièce du jour est double : le port « boîte »,
adws/sandbox_box.py, derrière lequel Podman est l’adaptateur par défaut et exe.dev l’option
échelle, et le cycle de vie en six phases, adws/sandbox_lifecycle.py avec son module
just sandbox, où le gardien du chapitre 21 devient la première recette.
Podman : une boîte fermée en une seconde
L’idée en une phrase
Une boîte de l’usine est trois objets Podman posés par du code déterministe : un
réseau interne (aucune route vers l’extérieur, les noms se résolvent entre membres), une
porte (un second conteneur, membre du réseau interne et du réseau ordinaire, qui ne fait
que deux choses : un proxy CONNECT sur liste d’hôtes, et un relais qui publie Plume sur
l’hôte) et le conteneur de travail (utilisateur ordinaire, aucune capacité, la porte pour
seule sortie). Podman est rootless et sans démon : chaque podman est un processus
enfant de votre runner, qui n’a jamais plus de droits que vous.
Points clés
- Le port d’abord, l’adaptateur ensuite.
sandbox_box.pydit ce qu’une phase a le droit de demander à une boîte (create,exists,exec,upload,url,destroy,names) et rien de plus. Aucun script du module n’appellepodmannisshdirectement, comme aucun ADW n’appellepihors du port du chapitre 7.SANDBOX_BACKENDdans.envchoisit l’adaptateur d’une boîte neuve, la fiche de run nomme celui d’une boîte montée, quel que soit votre.envdu jour. - La porte est la seule sortie, et elle refuse par défaut. Le conteneur de travail reçoit
HTTPS_PROXY=http://plume-gate-<run>:3128, que pi, uv, bun et curl honorent tous. La porte n’accepte queCONNECT(le tunnel TLS) versopenrouter.ai,pypi.org,files.pythonhosted.orgetregistry.npmjs.org. Tout le reste reçoit un403immédiat et une ligne danspodman logs: le journal d’egress de la boîte.SANDBOX_ALLOWélargit la liste si un run l’exige, et c’est vous qui le décidez, jamais l’agent. La porte résout elle-même les noms : membre d’un réseau interne, elle reçoit en tête le résolveur de Podman, qui répondNXDOMAINpour tout nom externe, et une réponse négative arrête la résolution, sans essayer le serveur suivant.resolve()interroge le résolveur système d’abord (les noms de conteneurs, dont la boîte pour le relais), puisSANDBOX_DNS(1.1.1.1par défaut). - Le plan de contrôle et le plan de données sont deux verbes.
podman network|create|run| rmcréent et détruisent (hôte seulement).podman exec -i <boîte> bash -c "…"agit dedans, un script sur stdin quand il le faut, exactement ce quessh <vm>fait chez exe.dev, et c’est pourquoi le port a la même forme pour les deux. - Adressable sans être exposée. Le conteneur de travail ne publie aucun port, c’est la porte
qui publie
127.0.0.1:<port libre>et relaie vers<boîte>:4500. Plume répond dans votre navigateur, la boîte reste invisible du réseau. - Une boîte coûte zéro et vit une seconde. Réseau, porte, conteneur : moins d’une seconde
chacun, l’image étant déjà là. Une boîte oubliée n’occupe qu’un peu de RAM :
just sandbox listles montre,teardownles rend. Chez exe.dev, la même boîte est une VM à quelques centimes de l’heure, avec un noyau à elle et un nom d’hôte public, l’échelle quand vous en aurez besoin.
Exemple concret
Comptez ce qu’un montage coûte réellement, sur Podman. create : le réseau, la porte (créée,
le script sandbox_gate.py copié dedans, démarrée), le conteneur : environ une seconde, et
podman port rend l’URL de Plume. Le chargement de plume-factory sans node_modules ni
runtime : quelques mégaoctets par podman exec sur stdin, une seconde. La provision, soit
bun install de Plume par la porte, tout le reste étant déjà dans l’image : quelques secondes.
La première résolution uv d’un script PEP 723 dans la boîte, PyPI par la porte : trois
secondes environ. La gate à sec prouve les outils, HEAD, .env, bun test, plus deux
choses nouvelles : openrouter.ai joignable par la porte, example.com refusé. En tout,
moins de vingt secondes entre just sandbox mount et une usine prête, pour zéro token.
La seule assertion qui coûte est le scout de la gate, moins d’un centime. Comparez au conteneur
nu du chapitre 21 : aussi rapide, mais réseau ouvert et sans porte.
L’anatomie d’une boîte
| Objet | Commande hôte | Rôle | Chez exe.dev |
|---|---|---|---|
Réseau interne plume-net-<run> | podman network create --internal | aucune route dehors ; DNS entre membres | le réseau de la VM (ouvert) |
Porte plume-gate-<run> | podman create … --network interne --network podman -p 127.0.0.1::4500 | proxy CONNECT sur liste + relais Plume + journal | le proxy HTTPS d’exe.dev (share port) |
Conteneur plume-box-<run> | podman run -d … --cap-drop ALL --pids-limit 512 sleep infinity | l’usine entière, HTTPS_PROXY → la porte | la VM elle-même |
| Agir dedans | podman exec -i … bash -c | le plan de données | ssh <vm> "…" |
| Tout détruire | podman rm -f ×2, podman network rm | destroy du port | ssh exe.dev rm <vm> |
Config — la fiche de run, la seule mémoire des six phases
Chaque phase est un processus séparé, et rien ne survit de l’une à l’autre sauf ce fichier.
Il est écrit avant la boîte : quoi qu’il arrive ensuite, teardown a une poignée. Il vit
sous adws/adw_data/sandbox/, couvert par votre .gitignore du chapitre 1, jamais commité,
jamais chargé dans une boîte. Le champ backend dit quel adaptateur retrouvera la boîte.
{
"run_id": "plume-20260905-3f9a1c",
"created_at": "2026-09-05T08:14:03Z",
"backend": "podman",
"box": "plume-box-plume-20260905-3f9a1c",
"url": "http://127.0.0.1:43127",
"commit_sha": "7c1e0b2a9d…",
"pid": 4812,
"closed_at": null
}
Piège courant : « je passe la clé avec
-e OPENROUTER_API_KEY=…à la création, c’est plus simple » est inexact. Une variable posée àpodman runest lisible par tout processus de votre machine danspodman inspect, reste dans l’historique de votre shell, et chez exe.dev n’atteint même pas les commandes lancées parssh(elle vit dans le profil des shells interactifs). Le port du chapitre 15 lit.envdans le repo, quel que soit le shell : c’est là quefillécrit le secret, par stdin, en 0600, et nulle part ailleurs.
create → fill → setup → execute → observe → teardown
L’idée en une phrase
Le cycle de vie d’une boîte est un graphe déterministe de six phases, chacune un processus
à part, reliées par la seule fiche de run. L’agent n’apparaît que dans execute, un nœud
borné comme depuis le chapitre 8, et aucune phase n’en détruit une autre en cas d’échec :
la boîte reste debout, la preuve dessus, jusqu’à ce que vous décidiez teardown.
Points clés
- L’ordre est le design. La fiche d’abord (un plantage plus loin laisse une poignée), la
boîte ensuite (réseau, porte, conteneur), le code après, le secret en dernier. Au
chapitre 23, la clé jetable prendra place exactement là où
fillécrit aujourd’hui votre clé personnelle : une seule ligne changera de source. fillcharge l’arbre exact, puis fige une référence. Un tar en flux, filtré par le code (jamaisnode_modules, jamaisadw_data, jamais.env, mais bien.gitet.env.sample), écrit sur le stdin depodman exec, puis un commit de référence dans la boîte : tout ce qui différera de ce sha sera, par définition, l’œuvre du run.- Les deux harnais veulent leur approbation. Le port passe
--approveà pi à chaque phase (chapitre A6). Claude Code, lui, lit un dialogue de confiance qu’aucun humain ne verra dans une boîte. La provision pose donchasTrustDialogAcceptedpour~/appdans~/.claude.json, sans quoi une phase sur le harnaisclaudesort en code 1 (« this workspace has not been trusted »), gate déterministe verte et scout rouge. setupprovisionne puis prouve, à sec d’abord. Une sentinelle (un fichier touché en toute dernière ligne du script de provision) sert de signal de fin. Vient ensuite une gate zéro token : outils présents,HEADégal à la fiche,.envnon vide, la porte laisse passer la passerelle et refuse un hôte hors liste,bun testvert. Puis une seule assertion payante, un scout sous roster, par le port du chapitre 7, qui prouveuv,pi, la clé, la porte et le registre d’un coup, pour moins d’un centime.executeest détaché ou n’est pas.nohup, redirections,< /dev/null: les trois sont nécessaires, sinonpodman exec, commessh, ne rend jamais la main. Le conteneur tourne avec un vrai init (--init) pour que le runner détaché ait un parent qui l’attende. Le pid part dans la fiche.observelitrun.loget la tablerunsdu module 5 depuis l’hôte, et sert Plume par le relais de la porte.teardownrapatrie avant de détruire, et n’est jamais enchaîné. Unrun.patch(le diff binaire depuis le commit de référence, nouveaux fichiers compris),factory.db,run.log, puisdestroy: conteneur, porte, réseau. La chaînemounts’arrête àobserve, par construction.
Exemple concret
Un SDLC du chapitre 13 dans la boîte : just sandbox execute <run> "Ajoute un compteur…".
Vous fermez le portable, la boîte reste ouverte : un conteneur ne s’arrête pas quand
votre terminal se ferme. Dix minutes plus tard, just sandbox observe <run> : le run est
terminé, run.log finit sur le bilan du runner, la table runs affiche une ligne success à
quelques dizaines de centimes, et Plume répond sur http://127.0.0.1:<port>. just sandbox egress <run> : quarante lignes de porte, des tunnel openrouter.ai:443 et quelques
tunnel pypi.org:443 de la résolution uv. Si une ligne refuse s’y glisse, c’est un outil
qui a voulu sortir hors liste et qui a continué sans. just sandbox teardown <run> rapatrie un run.patch de quelques kilo-octets et la
trace, puis détruit les trois objets. Coût total : quelques dizaines de centimes de tokens,
zéro euro d’infrastructure, et pas une minute de votre attention pendant le run, ce que le
chapitre 21 vous avait promis.
Les six phases face à la couture
| Phase | Côté | Produit | Si elle échoue |
|---|---|---|---|
| create | code, hôte seul | la fiche ; réseau, porte, conteneur ; l’URL | fiche gardée, boîte gardée |
| fill | code | le repo, un commit de référence, .env | boîte gardée pour inspection |
| setup | code (+ un scout borné) | outils, sentinelle, gate verte — porte prouvée | boîte gardée, gate rouge lisible |
| execute | agent dans un runner | un pid, run.log, la trace | le runner décide, comme chez vous |
| observe | code | lecture externe, Plume servie par la porte | rien n’est modifié |
| teardown | code | run.patch, factory.db, boîte détruite | rien détruit avant la preuve |
Commande — la surface just sandbox
Une seule version suffit ici : les deux harnais sont dans l’image et la boîte passe par le port
du chapitre 7, c’est le roster qui choisit. Une précision utile : dans une boîte, seul
OPENROUTER_API_KEY traverse et la porte ne connaît que la passerelle, donc c’est l’adaptateur
pi, en route passerelle, qui travaille. Claude Code y est présent mais sans clé ni sortie,
ce que le livre assume.
# la chaîne : create → fill → setup → observe — jamais teardown
just sandbox mount plume
# le travail, détaché — puis lire de l'extérieur, et lire la porte
just sandbox execute plume-20260905-3f9a1c "Ajoute un compteur de caractères à Plume"
just sandbox observe plume-20260905-3f9a1c
just sandbox egress plume-20260905-3f9a1c
# la preuve chez vous, puis la destruction — une décision, jamais un enchaînement
just sandbox teardown plume-20260905-3f9a1c
# l'option échelle : la même surface, une VM par boîte
# .env : SANDBOX_BACKEND=exedev (compte exe.dev, `ssh exe.dev whoami` une fois)
Aller plus loin — l’option exe.dev : ce qu’elle apporte, ce qu’elle coûte
Tout ce chapitre tourne sur votre machine, gratuitement. Un jour, ce sera la limite : trois
boîtes Podman tiennent sur un portable, dix non, et une boîte qui partage votre noyau reste,
au sens strict, chez vous. C’est pour ce jour-là que le port existe : SANDBOX_BACKEND=exedev
dans .env, et just sandbox mount monte la même boîte dans une VM louée chez exe.dev,
sans qu’un script change. Ce que vous y gagnez, et ce que vous y laissez, en toute franchise :
| Boîte Podman (défaut) | VM exe.dev (option) | |
|---|---|---|
| Ce que ça apporte | zéro euro, une seconde, réseau fermé par la porte | un noyau dédié (la faille noyau ne touche plus votre machine) ; N boîtes sans toucher à votre poste — le best-of-N du chapitre 25 à 5 bras sans regarder votre RAM ; un nom d’hôte public (https://<vm>.exe.xyz) pour montrer Plume à quelqu’un ; une boîte qui survit à votre portable fermé ou éteint |
| Ce que ça coûte | — | Personal : 20 $/mois (au moment d’écrire) pour un pool de 2 vCPU / 8 Go partagés entre vos VM, jusqu’à 50 VM, 100 Go de disque groupés (25 Go par VM par défaut), 200 Go de transfert ; disque supplémentaire ~0,08 $/Go/mois. Team : 25 $/utilisateur/mois. Pas de tier gratuit. |
| Ce que vous perdez | l’échelle hors de votre machine | la fermeture : le réseau d’une VM est ouvert, la porte du jour n’y est pas — la frontière retombe sur la seule clé jetable (chapitre 23) ; et le boot passe d’une seconde à quelques secondes, le montage complet à moins d’une minute |
| Quand basculer | par défaut, et pour tout le livre | quand la RAM borne votre best-of-N, quand vous voulez une URL publique, ou quand le noyau partagé n’est plus un risque acceptable pour le code que vous laissez tourner |
Deux nuances à connaître avant de payer. Le pool de 2 vCPU / 8 Go est partagé : cinq bras
de best-of-N se partagent la même puissance, et un bun install dans cinq VM en même temps se
sent. Et une VM oubliée ne facture pas à l’heure, mais elle occupe votre pool et, si un agent y
tourne encore, dépense la clé de son run : le teardown reste une décision, mais une décision à
prendre. Enfin, l’abonnement inclut un quota de jetons vers des modèles Anthropic depuis les VM
(l’« intégration LLM », sans clé sur la machine). Le chapitre 24 s’en servira pour la variante
Claude Code de l’orchestrateur en boîte, la seule fonctionnalité du module réservée à exe.dev.
Pour Plume et pour ce livre, Podman suffit. exe.dev est là quand l’usine dépasse votre bureau.
Piège courant : « si la gate de setup est rouge, autant détruire la boîte et recommencer » est inexact. Une gate qui détruit jette la preuve avec la boîte. La gate rouge vous laisse la boîte debout, le motif exact (
KO bun test rouge dans la boite, ouKO example.com joignable : la boite n'est PAS fermee), et une entrée pour aller voir. Réparer coûte une commande, recommencer coûte le diagnostic. C’est la même leçon qu’au chapitre 13, appliquée à la machine plutôt qu’à l’agent.
Fil rouge — la pièce posée aujourd’hui
Sur le plan de l’usine, la zone just/sandbox/ reçoit sa première pièce, lifecycle.just,
montée en module sandbox par le justfile du chapitre 5, avec sa logique dans
adws/sandbox_lifecycle.py, comme obs.just s’appuie sur obs_lanes.py depuis le
chapitre 19. En dessous, un nouveau port : adws/sandbox_box.py, la boîte vue de l’usine,
avec deux adaptateurs (Podman, exe.dev) et sa porte adws/sandbox_gate.py. Le préflight d’hier
en devient la première recette. La loi ne bouge pas, l’agent propose et le code dispose, mais
ici le code possède aussi la machine et le réseau : cinq phases sur six sont purement
déterministes, la sixième contient l’agent dans le runner qu’il connaît déjà, et tout ce qui
sort de la boîte passe par une porte que le code a configurée. L’enveloppe qui traverse la
couture est inchangée depuis le chapitre 9. Ce qui traverse la frontière, c’est l’usine entière
dans un sens, run.patch et la trace dans l’autre, et vers l’extérieur rien d’autre que ce
que la liste autorise. À l’usage : une vingtaine de secondes et zéro token pour monter, moins
d’un centime pour prouver, le prix du run pour travailler, zéro euro de machine. En
retour, la première capacité qu’un agent seul n’aura jamais : tourner sans vous, dans une pièce
fermée.
Travaux pratiques — la pièce du jour
Cinq fichiers à poser dans le repo compagnon plume-factory, qui devient, chapitre après
chapitre, votre usine logicielle agentique : le port « boîte » et ses deux adaptateurs, la
porte, le cycle de vie, son module just, et le justfile qui le monte (une ligne). C’est le
chapitre le plus chargé du module, et les trois premiers fichiers ne changeront plus.
Prérequis : Podman rootless et l’image plume-node du chapitre 21. Pour l’option exe.dev : un
compte (abonnement payant, comptez une vingtaine de dollars par mois pour le plus petit
palier), ssh exe.dev whoami depuis votre terminal en vérifiant l’empreinte affichée au
premier contact contre celle de la documentation, et SANDBOX_BACKEND=exedev dans .env.
Pièce — adws/sandbox_box.py
Le port et ses deux adaptateurs, bibliothèque standard uniquement. Box est le contrat,
PodmanBox pose réseau, porte et conteneur et agit par podman exec, ExeDevBox reprend le
plan de contrôle et le plan de données SSH. load() choisit l’adaptateur d’une boîte neuve par
.env, for_record() celui d’une boîte montée par sa fiche. C’est le seul fichier du module
qui connaisse podman ou ssh.
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# ///
"""sandbox_box — le port « boite » du hors-site : un contrat, deux adaptateurs.
Une boite est un environnement JETABLE, FERME et BORNE ou l'usine entiere
tourne. Ce module dit ce que les phases (sandbox_lifecycle), les
orchestrateurs (sandbox_orch) et le best-of-N (sandbox_bestof) ont le droit
de demander a une boite — et rien de plus. Aucun d'eux n'appelle `podman`
ni `ssh` directement : tout passe par ici, comme les agents passent par le
port harnais du ch. 7.
create(run_id, flags) -> dict monter la boite ; rend ce que la fiche retient
exists(run_id) -> bool la boite est-elle vivante ?
argv(record, command, tty=False) la commande hote qui execute `command` DANS la boite
call(record, command, *, script=None, binary=False) -> CompletedProcess
exec(record, command, *, script=None, binary=False, check=True) -> stdout
ok(record, command) -> bool vrai si la commande rend 0 dans la boite
upload(record, write) un tar.gz ecrit sur stdin, extrait dans ~/app
url(record) -> str | None ou Plume repond depuis l'hote
destroy(record) tout detruire — la seule methode qui detruit
names() -> set[str] | None les run_id vivants, None si le backend est injoignable
Deux adaptateurs, choisis par SANDBOX_BACKEND (dans .env) :
podman (defaut) — un conteneur rootless sur un reseau INTERNE (aucune route
vers l'exterieur) et une PORTE (sandbox_gate.py) dans un second conteneur,
seule sortie, sur liste d'hotes. Gratuit, local, une seconde.
exedev — une VM chez exe.dev, pilotee par SSH : plan de controle
(`ssh exe.dev <verbe>`) et plan de donnees (`ssh <vm> "<commande>"`).
Payant, distant, N boites sans toucher a votre machine : l'option « echelle ».
Bibliotheque standard uniquement : ce module tourne sur l'hote avant toute
chaine d'outils, et un demontage ne doit jamais attendre.
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
BACKENDS = ("podman", "exedev")
DEFAULT_BACKEND = "podman"
def die(message: str, code: int = 1) -> None:
print(f"box : {message}", file=sys.stderr)
sys.exit(code)
def load_env(path: Path = Path(".env")) -> None:
"""Le contrat du ch. 15 : .env dans l'environnement, sans jamais l'ecraser."""
if not path.is_file():
return
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
os.environ.setdefault(key.strip(), value.strip().strip("'\""))
def _run(argv: list[str], *, check: bool = True, **kwargs) -> subprocess.CompletedProcess:
"""Une commande hote, resolue par le PATH (shims Windows compris), UTF-8."""
executable = shutil.which(argv[0])
if executable is None:
die(f"{argv[0]!r} introuvable dans le PATH — `just sandbox preflight` le verifie")
kwargs.setdefault("capture_output", True)
if not kwargs.pop("binary", False):
kwargs.update(text=True, encoding="utf-8", errors="replace")
proc = subprocess.run([executable, *argv[1:]], **kwargs)
if check and proc.returncode != 0:
err = proc.stderr if isinstance(proc.stderr, str) else proc.stderr.decode(errors="replace")
die(f"`{' '.join(argv[:4])}…` a rendu {proc.returncode} : {err.strip()[-400:]}")
return proc
# ── le port : ce que tout le monde partage ──────────────────────────────────
class Box:
"""Le contrat. Les deux adaptateurs heritent des methodes communes
(call, exec, ok, upload) et fournissent argv, create, exists, url, destroy, names."""
name = "?"
def argv(self, record: dict, command: str, *, tty: bool = False) -> list[str]:
raise NotImplementedError
def create(self, run_id: str, flags: list[str]) -> dict:
raise NotImplementedError
def exists(self, run_id: str) -> bool:
raise NotImplementedError
def url(self, record: dict) -> str | None:
raise NotImplementedError
def destroy(self, record: dict) -> None:
raise NotImplementedError
def names(self) -> set[str] | None:
raise NotImplementedError
def call(self, record: dict, command: str, *, script: str | None = None,
binary: bool = False) -> subprocess.CompletedProcess:
"""Une commande DANS la boite : le processus complet (sortie, erreur, code).
script= l'envoie sur stdin (`bash -s`) : rien n'a a survivre a deux
couches de quoting, un secret n'apparait jamais dans argv. Sans script,
stdin est ferme : un enfant qui herite de notre stdin peut attendre
une entree qui ne viendra jamais."""
kwargs: dict = {"binary": binary}
if script is None:
kwargs["stdin"] = subprocess.DEVNULL
else:
kwargs["input"] = script.encode() if binary else script
return _run(self.argv(record, command), check=False, **kwargs)
def exec(self, record: dict, command: str, *, script: str | None = None,
binary: bool = False, check: bool = True):
"""La sortie d'une commande DANS la boite ; check=True meurt sur un code non nul."""
proc = self.call(record, command, script=script, binary=binary)
if check and proc.returncode != 0:
err = proc.stderr if isinstance(proc.stderr, str) else proc.stderr.decode(errors="replace")
die(f"dans la boite, `{command[:60]}` a rendu {proc.returncode} : {err.strip()[-400:]}")
return proc.stdout
def ok(self, record: dict, command: str) -> bool:
return _run(self.argv(record, command), check=False, stdin=subprocess.DEVNULL).returncode == 0
def upload(self, record: dict, write) -> None:
"""Le repo en flux : un tar.gz ecrit sur stdin de la boite, extrait dans ~/app."""
proc = subprocess.Popen(self.argv(record, "rm -rf app && mkdir -p app && tar xzf - -C app"),
stdin=subprocess.PIPE)
write(proc.stdin)
proc.stdin.close()
if proc.wait() != 0:
die("le chargement du repo a echoue — la boite est gardee")
# ── adaptateur 1 : Podman, rootless, reseau interne + porte ─────────────────
class PodmanBox(Box):
name = "podman"
IMAGE = "localhost/plume-node:latest" # construite par `just sandbox image` (Containerfile, ch. 21)
GATE_SCRIPT = Path("adws/sandbox_gate.py") # la porte, copiee dans son conteneur a la creation
PROXY_PORT = 3128
APP_PORT = 4500
LABEL = "plume-factory"
@staticmethod
def box_name(run_id: str) -> str: return f"plume-box-{run_id}"
@staticmethod
def gate_name(run_id: str) -> str: return f"plume-gate-{run_id}"
@staticmethod
def net_name(run_id: str) -> str: return f"plume-net-{run_id}"
def argv(self, record: dict, command: str, *, tty: bool = False) -> list[str]:
return ["podman", "exec", "-it" if tty else "-i", self.box_name(record["run_id"]), "bash", "-c", command]
def create(self, run_id: str, flags: list[str]) -> dict:
if _run(["podman", "image", "exists", self.IMAGE], check=False).returncode != 0:
die(f"image {self.IMAGE} absente — `just sandbox image` la construit (une fois, ~2 min)")
if not self.GATE_SCRIPT.is_file():
die(f"{self.GATE_SCRIPT} absent — la porte fait partie de l'usine")
box, gate, net = self.box_name(run_id), self.gate_name(run_id), self.net_name(run_id)
allow = os.environ.get("SANDBOX_ALLOW", "openrouter.ai,pypi.org,files.pythonhosted.org,registry.npmjs.org")
labels = [f"--label={self.LABEL}.run={run_id}"]
# 1. le reseau INTERNE : aucune route vers l'exterieur, les noms se resolvent entre membres.
_run(["podman", "network", "create", "--internal", *labels, net])
# 2. la porte : membre du reseau interne ET du reseau ordinaire — la seule sortie.
# Le proxy n'ouvre que vers la liste ; le relais publie Plume sur l'hote (127.0.0.1, port libre).
_run(["podman", "create", "--name", gate, f"--label={self.LABEL}.role=gate", *labels,
"--network", net, "--network", "podman",
# La porte est le seul conteneur qui sort, donc le seul qui doit
# resoudre des noms — et le reseau interne lui impose son propre
# resolveur en tete. --dns pose le secours que sandbox_gate.resolve
# interroge quand le premier a repondu NXDOMAIN. La boite, elle,
# n'a toujours aucun DNS externe : elle passe des NOMS au proxy.
"--dns", os.environ.get("SANDBOX_DNS", "1.1.1.1"),
"-p", f"127.0.0.1::{self.APP_PORT}",
self.IMAGE, "python3", "/gate.py", "--listen", str(self.PROXY_PORT), "--allow", allow,
"--relay", f"{self.APP_PORT}:{box}:{self.APP_PORT}"])
_run(["podman", "cp", str(self.GATE_SCRIPT), f"{gate}:/gate.py"])
_run(["podman", "start", gate])
# 3. la boite : reseau interne seulement, la porte comme proxy, aucune capacite,
# pas de root (USER de l'image), un plafond de processus, un vrai init (--init)
# pour que les runs detaches d'execute aient un parent. `sleep infinity` :
# le conteneur vit, les phases entrent par exec.
_run(["podman", "run", "-d", "--init", "--name", box, "--hostname", run_id,
f"--label={self.LABEL}.role=box", *labels, "--network", net,
"-e", f"HTTPS_PROXY=http://{gate}:{self.PROXY_PORT}",
"-e", f"HTTP_PROXY=http://{gate}:{self.PROXY_PORT}",
"-e", "NO_PROXY=localhost,127.0.0.1", "-e", "PI_OFFLINE=1",
"--cap-drop", "ALL", "--security-opt", "no-new-privileges", "--pids-limit", "512",
*flags, self.IMAGE, "sleep", "infinity"])
return {"backend": self.name, "box": box, "url": self.url({"run_id": run_id})}
def exists(self, run_id: str) -> bool:
return _run(["podman", "container", "exists", self.box_name(run_id)], check=False).returncode == 0
def url(self, record: dict) -> str | None:
out = _run(["podman", "port", self.gate_name(record["run_id"]), str(self.APP_PORT)],
check=False).stdout.strip().splitlines()
return f"http://{out[0].strip()}" if out else None
def destroy(self, record: dict) -> None:
run_id = record["run_id"]
_run(["podman", "rm", "-f", "--ignore", self.box_name(run_id), self.gate_name(run_id)], check=False)
_run(["podman", "network", "rm", "-f", self.net_name(run_id)], check=False)
def names(self) -> set[str] | None:
proc = _run(["podman", "ps", "-a", "--filter", f"label={self.LABEL}.role=box",
"--format", "{{.Names}}"], check=False)
if proc.returncode != 0:
return None
prefix = self.box_name("")
return {line[len(prefix):] for line in proc.stdout.split() if line.startswith(prefix)}
# ── adaptateur 2 : exe.dev, une VM par boite, tout passe par SSH ────────────
class ExeDevBox(Box):
name = "exedev"
TAG = "plume-factory"
APP_PORT = 4500
# BatchMode : jamais de question interactive dans une phase. accept-new : la
# cle d'hote d'une VM neuve est memorisee au premier contact.
SSH_OPTS = ["-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=15"]
@staticmethod
def dest(run_id: str) -> str: return f"{run_id}.exe.xyz"
def control(self, *args: str, check: bool = True) -> str:
"""Le plan de controle : `ssh exe.dev <verbe>` — creer, lister, exposer, detruire."""
return _run(["ssh", *self.SSH_OPTS, "exe.dev", *args], check=check).stdout
def argv(self, record: dict, command: str, *, tty: bool = False) -> list[str]:
return ["ssh", *(["-t"] if tty else []), *self.SSH_OPTS, self.dest(record["run_id"]), command]
def find_vm(self, run_id: str) -> dict | None:
try:
data = json.loads(self.control("ls", "--json", check=False) or "{}")
except json.JSONDecodeError:
return None
return next((vm for vm in data.get("vms") or [] if vm.get("vm_name") == run_id), None)
def create(self, run_id: str, flags: list[str]) -> dict:
self.control("new", "--name", run_id, "--tag", self.TAG, *flags, "--json")
vm = self.find_vm(run_id)
if vm is None:
die(f"la VM {run_id} n'apparait pas dans `ssh exe.dev ls` — la fiche est gardee")
return {"backend": self.name, "box": vm.get("ssh_dest") or self.dest(run_id), "url": vm.get("https_url")}
def exists(self, run_id: str) -> bool:
return self.find_vm(run_id) is not None
def url(self, record: dict) -> str | None:
# Le proxy HTTPS de la VM vise Plume. Prive par defaut : votre compte
# exe.dev ouvre la page ; `ssh exe.dev share set-public <vm>` est un geste a vous.
self.control("share", "port", record["run_id"], str(self.APP_PORT), check=False)
return record.get("url")
def destroy(self, record: dict) -> None:
self.control("rm", record["run_id"])
def names(self) -> set[str] | None:
proc = _run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=15", "exe.dev", "ls", "--json"], check=False)
if proc.returncode != 0:
return None
try:
return {vm.get("vm_name") for vm in json.loads(proc.stdout).get("vms") or []}
except json.JSONDecodeError:
return None
# ── le choix : l'environnement pour une boite neuve, la fiche pour une boite montee ──
ADAPTERS = {"podman": PodmanBox, "exedev": ExeDevBox}
def backend_name() -> str:
load_env()
name = os.environ.get("SANDBOX_BACKEND", DEFAULT_BACKEND).strip().lower()
if name not in ADAPTERS:
die(f"SANDBOX_BACKEND={name!r} inconnu — disponibles : {', '.join(BACKENDS)}")
return name
def load(name: str | None = None) -> Box:
"""L'adaptateur d'une boite NEUVE : SANDBOX_BACKEND, podman par defaut."""
return ADAPTERS[name or backend_name()]()
def for_record(record: dict) -> Box:
"""L'adaptateur d'une boite MONTEE : celui que sa fiche nomme, quel que soit .env aujourd'hui."""
return ADAPTERS[record.get("backend") or DEFAULT_BACKEND]()
if __name__ == "__main__":
# La gate du module — zero reseau, zero conteneur : les argv des deux
# adaptateurs et le choix du backend. Lancer depuis la racine :
# uv run adws/sandbox_box.py
record = {"run_id": "plume-20260905-3f9a1c", "backend": "podman"}
p = PodmanBox().argv(record, "cd app && bun test")
assert p[:3] == ["podman", "exec", "-i"] and p[3] == "plume-box-plume-20260905-3f9a1c" and p[-1] == "cd app && bun test"
assert PodmanBox().argv(record, "bash -l", tty=True)[2] == "-it"
e = ExeDevBox().argv({"run_id": "plume-20260905-3f9a1c"}, "cd app && bun test")
assert e[0] == "ssh" and "plume-20260905-3f9a1c.exe.xyz" in e and "-t" not in e
assert ExeDevBox().argv({"run_id": "x"}, "bash -l", tty=True)[1] == "-t"
assert isinstance(for_record(record), PodmanBox) and isinstance(for_record({"backend": "exedev"}), ExeDevBox)
os.environ["SANDBOX_BACKEND"] = "exedev"
assert backend_name() == "exedev"
os.environ["SANDBOX_BACKEND"] = "podman"
print("sandbox_box OK — deux adaptateurs derriere un port, argv exec/tty corrects, backend choisi par .env"
" pour une boite neuve et par la fiche pour une boite montee")
Pièce — adws/sandbox_gate.py
La porte : un proxy CONNECT sur liste d’hôtes et un relais TCP, en un fichier sans
dépendance, que PodmanBox.create copie dans le conteneur de porte. Ses décisions sont des
fonctions pures (parse_request, allowed, verdict), testées par sa gate sur la boucle
locale. Chaque tunnel et chaque refus s’écrivent sur stdout, que podman logs vous rend.
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# ///
"""sandbox_gate — la porte d'une boite : ce qui sort passe par ici, ou ne sort pas.
La boite du hors-site vit sur un reseau INTERNE (aucune route vers l'exterieur).
Ce script tourne dans un second conteneur, la porte, branche sur ce reseau ET
sur le reseau ordinaire. Il rend deux services, et rien d'autre :
1. un proxy CONNECT (HTTPS_PROXY dans la boite) qui n'ouvre un tunnel que
vers les hotes d'une LISTE — openrouter.ai, pypi.org, registry.npmjs.org…
Tout le reste est refuse, en une ligne : 403 + le motif. Refus par defaut.
2. un relais TCP : le port de Plume dans la boite, publie sur l'hote a
travers la porte — la boite reste adressable sans etre exposee.
Chaque decision s'ecrit sur stdout : `podman logs` est le journal d'egress
de la boite. Bibliotheque standard uniquement : la porte tourne dans une
image qui n'a rien d'autre que python3.
python3 sandbox_gate.py --listen 3128 --allow openrouter.ai,pypi.org --relay 4500:plume-box-x:4500
uv run adws/sandbox_gate.py --selftest # la gate a sec : loopback seulement
"""
from __future__ import annotations
import argparse
import os
import random
import socket
import struct
import sys
import threading
import time
DEFAULT_ALLOW = "openrouter.ai,pypi.org,files.pythonhosted.org,registry.npmjs.org"
DNS_ENV = "SANDBOX_DNS" # le resolveur de la porte quand celui du conteneur ne sort pas
DEFAULT_DNS = "1.1.1.1"
HEAD_MAX = 8192 # un en-tete CONNECT tient en quelques lignes ; au-dela, c'est autre chose
IO_TIMEOUT = 15 # secondes : une porte n'attend jamais indefiniment
def log(message: str) -> None:
print(f"{time.strftime('%H:%M:%S')} {message}", flush=True)
# ── les decisions : pures, donc testables a sec ─────────────────────────────
def parse_request(head: bytes) -> tuple[str, str, int] | None:
"""La premiere ligne d'une requete HTTP → (methode, hote, port), ou None.
`CONNECT openrouter.ai:443 HTTP/1.1` → ("CONNECT", "openrouter.ai", 443).
`GET http://pypi.org/x HTTP/1.1` → ("GET", "pypi.org", 80)."""
try:
line = head.split(b"\r\n", 1)[0].decode("ascii")
method, target, _version = line.split(" ", 2)
except (UnicodeDecodeError, ValueError):
return None
if method == "CONNECT":
host, _, port = target.rpartition(":")
return (method, host.strip("[]").lower(), int(port)) if host and port.isdigit() else None
if "://" in target:
hostport = target.split("://", 1)[1].split("/", 1)[0]
host, _, port = hostport.partition(":")
return (method, host.lower(), int(port) if port.isdigit() else 80)
return None
def allowed(host: str, allow: set[str]) -> bool:
"""Un hote passe s'il est dans la liste, ou sous-domaine d'un nom de la liste.
Jamais de motif : `api.openrouter.ai` passe pour `openrouter.ai`, `openrouter.ai.evil.test` non."""
return any(host == name or host.endswith("." + name) for name in allow)
def verdict(head: bytes, allow: set[str]) -> tuple[str, str, int] | tuple[None, str, int]:
"""(hote, port) a joindre, ou (None, motif, code HTTP) — la seule fonction qui juge."""
parsed = parse_request(head)
if parsed is None:
return None, "requete illisible", 400
method, host, port = parsed
if method != "CONNECT":
return None, f"seul CONNECT (TLS) est autorise, pas {method} en clair", 403
if not allowed(host, allow):
return None, f"hote hors liste : {host}", 403
return host, "", port
# ── la plomberie : deux tubes, dans les deux sens ────────────────────────────
def pump(a: socket.socket, b: socket.socket) -> None:
"""Copie a→b puis ferme b en ecriture : l'autre sens tourne dans son propre thread."""
try:
while True:
chunk = a.recv(65536)
if not chunk:
break
b.sendall(chunk)
except OSError:
pass
finally:
try:
b.shutdown(socket.SHUT_WR)
except OSError:
pass
# ── resoudre : la porte ne peut pas compter sur le resolveur du conteneur ────
# Membre d'un reseau --internal, la porte recoit en tete le resolveur interne
# de podman, qui repond NXDOMAIN pour tout nom externe. Une reponse negative
# ARRETE la resolution : le serveur suivant n'est jamais essaye, meme pose par
# --dns. La porte resout donc elle-meme — le resolveur systeme d'abord (il sait
# les noms de conteneurs, dont la boite pour le relais), un serveur explicite
# ensuite (SANDBOX_DNS). Le cache evite une requete par tunnel : un lot en ouvre
# des dizaines vers les trois memes hotes.
_resolved: dict[str, str] = {}
_resolve_lock = threading.Lock()
def dns_query(host: str, server: str, timeout: float = 3.0) -> str | None:
"""Une requete DNS A minimale : un paquet UDP, une reponse, la premiere adresse."""
qname = b"".join(bytes([len(p)]) + p for p in host.encode("idna").split(b".")) + b"\x00"
ident = random.getrandbits(16)
packet = struct.pack(">HHHHHH", ident, 0x0100, 1, 0, 0, 0) + qname + struct.pack(">HH", 1, 1)
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.settimeout(timeout)
try:
sock.sendto(packet, (server, 53))
data, _ = sock.recvfrom(1024)
except OSError:
return None
if len(data) < 12 or struct.unpack(">H", data[:2])[0] != ident:
return None
offset = 12 + len(qname) + 4 # en-tete + la question qu'on vient d'ecrire
for _ in range(struct.unpack(">H", data[6:8])[0]):
while offset < len(data): # sauter le nom : un pointeur, ou des labels
length = data[offset]
if length & 0xC0 == 0xC0:
offset += 2
break
offset += 1 + length
if length == 0:
break
if offset + 10 > len(data):
return None
rtype, _, _, rdlen = struct.unpack(">HHIH", data[offset:offset + 10])
offset += 10
if rtype == 1 and rdlen == 4: # un A : c'est ce qu'on cherche
return socket.inet_ntoa(data[offset:offset + 4])
offset += rdlen # un CNAME : la chaine continue
return None
def resolve(host: str) -> str:
"""Le nom devient une adresse — resolveur systeme, puis SANDBOX_DNS en secours."""
with _resolve_lock:
if host in _resolved:
return _resolved[host]
try:
address = socket.gethostbyname(host)
except OSError:
address = dns_query(host, os.environ.get(DNS_ENV, DEFAULT_DNS))
if address is None:
raise OSError(f"nom non resolu : {host}")
with _resolve_lock:
_resolved[host] = address
return address
def bridge(a: socket.socket, b: socket.socket) -> None:
t = threading.Thread(target=pump, args=(b, a), daemon=True)
t.start()
pump(a, b)
t.join(timeout=IO_TIMEOUT)
for s in (a, b):
try:
s.close()
except OSError:
pass
def read_head(conn: socket.socket) -> bytes:
conn.settimeout(IO_TIMEOUT)
head = b""
while b"\r\n\r\n" not in head and len(head) < HEAD_MAX:
chunk = conn.recv(1024)
if not chunk:
break
head += chunk
return head
def handle_proxy(conn: socket.socket, allow: set[str]) -> None:
try:
host, reason, port = verdict(read_head(conn), allow)
if host is None:
log(f"refuse {reason}")
conn.sendall(f"HTTP/1.1 {port} Forbidden\r\nContent-Length: 0\r\nX-Gate: {reason}\r\n\r\n".encode())
conn.close()
return
upstream = socket.create_connection((resolve(host), port), timeout=IO_TIMEOUT)
conn.sendall(b"HTTP/1.1 200 Connection Established\r\n\r\n")
conn.settimeout(None)
upstream.settimeout(None)
log(f"tunnel {host}:{port}")
bridge(conn, upstream)
except OSError as error:
log(f"erreur {error}")
try:
conn.close()
except OSError:
pass
def handle_relay(conn: socket.socket, target: tuple[str, int]) -> None:
try:
upstream = socket.create_connection((resolve(target[0]), target[1]), timeout=IO_TIMEOUT)
upstream.settimeout(None)
bridge(conn, upstream)
except OSError as error:
log(f"relais {target[0]}:{target[1]} injoignable ({error})")
conn.close()
def listen(port: int, handler, *args) -> socket.socket:
"""Un accepteur en arriere-plan ; rend la socket (utile pour lire le port choisi par l'OS)."""
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("0.0.0.0", port))
server.listen(64)
def loop() -> None:
while True:
try:
conn, _ = server.accept()
except OSError:
return
threading.Thread(target=handler, args=(conn, *args), daemon=True).start()
threading.Thread(target=loop, daemon=True).start()
return server
def parse_relay(spec: str) -> tuple[int, tuple[str, int]]:
"""`4500:plume-box-x:4500` → (4500, ("plume-box-x", 4500))."""
listen_port, host, port = spec.split(":")
return int(listen_port), (host, int(port))
# ── la gate a sec : loopback seulement, zero reseau, zero jeton ─────────────
def selftest() -> int:
allow = {"exemple.test", "openrouter.ai"}
ok = parse_request(b"CONNECT openrouter.ai:443 HTTP/1.1\r\nHost: x\r\n\r\n") == ("CONNECT", "openrouter.ai", 443)
ok &= parse_request(b"GET http://pypi.org/simple/ HTTP/1.1\r\n\r\n") == ("GET", "pypi.org", 80)
ok &= parse_request(b"n'importe quoi") is None
ok &= allowed("api.openrouter.ai", allow) and not allowed("openrouter.ai.evil.test", allow)
ok &= verdict(b"CONNECT api.anthropic.com:443 HTTP/1.1\r\n\r\n", allow)[0] is None
ok &= verdict(b"GET http://exemple.test/ HTTP/1.1\r\n\r\n", allow)[2] == 403
ok &= verdict(b"CONNECT exemple.test:443 HTTP/1.1\r\n\r\n", allow) == ("exemple.test", "", 443)
ok &= resolve("127.0.0.1") == "127.0.0.1" # une adresse litterale traverse sans requete
# Le proxy, en vrai, sur loopback : un hote hors liste recoit 403 sans qu'aucune connexion sorte.
proxy = listen(0, handle_proxy, allow)
with socket.create_connection(("127.0.0.1", proxy.getsockname()[1])) as c:
c.sendall(b"CONNECT api.anthropic.com:443 HTTP/1.1\r\nHost: api.anthropic.com\r\n\r\n")
ok &= c.recv(1024).startswith(b"HTTP/1.1 403")
# Le relais, en vrai : un echo derriere, les octets reviennent intacts.
echo = listen(0, lambda conn: (conn.sendall(conn.recv(64)), conn.close()))
relay = listen(0, handle_relay, ("127.0.0.1", echo.getsockname()[1]))
with socket.create_connection(("127.0.0.1", relay.getsockname()[1])) as c:
c.sendall(b"plume")
ok &= c.recv(64) == b"plume"
for s in (proxy, echo, relay):
s.close()
print(f"sandbox_gate {'OK' if ok else 'KO'} — requetes lues, liste par suffixe, hote hors liste refuse (403),"
" resolution locale, relais TCP intact")
return 0 if ok else 1
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="la porte d'une boite : proxy CONNECT sur liste + relais TCP")
parser.add_argument("--selftest", action="store_true", help="la gate a sec, loopback seulement")
parser.add_argument("--listen", type=int, default=3128, help="le port du proxy CONNECT")
parser.add_argument("--allow", default=DEFAULT_ALLOW, help="hotes autorises, separes par des virgules")
parser.add_argument("--relay", action="append", default=[], help="ecoute:hote:port — un relais TCP vers la boite")
args = parser.parse_args()
if args.selftest:
raise SystemExit(selftest())
allow_set = {name.strip().lower() for name in args.allow.split(",") if name.strip()}
listen(args.listen, handle_proxy, allow_set)
log(f"porte proxy :{args.listen} — hotes autorises : {', '.join(sorted(allow_set))}")
for spec in args.relay:
port, target = parse_relay(spec)
listen(port, handle_relay, target)
log(f"porte relais :{port} → {target[0]}:{target[1]}")
while True: # les accepteurs vivent dans leurs threads ; ce processus attend
time.sleep(3600)
Pièce — adws/sandbox_lifecycle.py
Le cycle de vie complet, bibliothèque standard uniquement : il tourne sur l’hôte avant toute
chaîne d’outils et ne doit jamais être la raison pour laquelle un démontage ne peut pas
commencer. Il s’appuie sur le port du jour (jamais sur podman directement), sur le
.gitignore du chapitre 1 (la fiche vit sous adw_data/), sur le port du chapitre 15 (.env
lu dans la boîte), sur le scout du chapitre 11 (l’assertion payante de la gate), sur le SDLC du
chapitre 13 (le travail par défaut d’execute) et sur la trace du module 5 (observe la lit,
teardown la rapatrie). Le seul secret qui traverse est votre clé OpenRouter, écrite par stdin
et jamais affichée. Le chapitre 23 remplacera cette ligne par une clé jetable et plafonnée.
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# ///
"""sandbox_lifecycle — le cycle de vie d'une boite du hors-site.
create → fill → setup → execute → observe → teardown
Six phases, six processus separes : rien ne survit de l'une a l'autre, sauf
la FICHE DE RUN sur disque (adws/adw_data/sandbox/<run>.json). Elle est
ecrite AVANT la boite : quoi qu'il arrive ensuite, teardown a une poignee.
Aucune phase ne detruit jamais une boite en cas d'echec — la preuve reste
sur la boite. Le demontage est une decision a vous : `teardown`, jamais
enchaine.
La boite elle-meme vient du port sandbox_box.py : Podman (defaut) ou exe.dev,
choisi par SANDBOX_BACKEND dans .env pour une boite neuve, et par la fiche
pour une boite montee. Rien ici n'appelle `podman` ni `ssh` directement.
Bibliotheque standard uniquement : ce script tourne sur l'hote avant toute
chaine d'outils, et ne doit jamais etre la raison pour laquelle un
demontage ne peut pas commencer.
uv run adws/sandbox_lifecycle.py mount <nom> [options du moteur : --memory 4g, --cpus 2…]
uv run adws/sandbox_lifecycle.py create|fill|setup|observe|teardown <run>
uv run adws/sandbox_lifecycle.py execute <run> "<demande>" [--config ...]
uv run adws/sandbox_lifecycle.py list
uv run adws/sandbox_lifecycle.py --selftest # la gate a sec, zero reseau
"""
from __future__ import annotations
import argparse
import json
import os
import re
import secrets
import shlex
import sys
import tarfile
import tempfile
import time
from datetime import datetime, timezone
from pathlib import Path
# Le port « boite » : Podman ou exe.dev, derriere le meme contrat (meme
# dossier : uv place adws/ en tete du chemin d'import).
import sandbox_box as boxes
from sandbox_box import die, load_env
RUNS_DIR = Path("adws/adw_data/sandbox") # couvert par le .gitignore du ch. 1
APP_PORT = 4500 # le port de Plume depuis le ch. 2
GIT_IDENTITY = ["-c", "user.name=plume-factory", "-c", "user.email=factory@plume.local"]
# Ce qui ne voyage pas : les dependances (reinstallees dans la boite), le
# runtime (la boite ecrit le sien), les secrets (le SEUL qui traverse est
# ecrit a part, par fill). .env.sample, lui, voyage : c'est un gabarit.
EXCLUDED_PARTS = {"node_modules", "adw_data", "dist", "build", "__pycache__", ".venv"}
# Ce que setup installe : l'image plume-node (Containerfile, ch. 21) apporte
# deja tout ; l'image exe.dev apporte uv, pi, claude, git, sqlite3 et il lui
# manque bun et just — installes dans le HOME, sans sudo. La sentinelle est
# le signal de fin.
PROVISION = r"""
set -euo pipefail
export PATH="$HOME/.bun/bin:$HOME/.local/bin:/usr/local/bin:$PATH"
rm -f "$HOME/.plume-factory-ready"
if ! command -v bun >/dev/null 2>&1; then
curl -fsSL https://bun.sh/install | bash >/dev/null 2>&1
fi
if ! command -v just >/dev/null 2>&1; then
mkdir -p "$HOME/.local/bin"
curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to "$HOME/.local/bin" >/dev/null 2>&1
fi
cd "$HOME/app/apps/plume" && bun install --silent
# Claude Code refuse un depot qu'un humain n'a pas approuve dans son dialogue
# de confiance : en boite, personne n'est la pour cliquer. C'est le pendant
# exact du --approve que le port passe a pi (annexe A6) — sans lui, une phase
# sur le harnais claude sort en code 1 avec « this workspace has not been
# trusted », gate deterministe verte et scout rouge.
# Une seule ligne, sans heredoc : bash lit ce script depuis stdin, un document
# en ligne y consommerait le meme flux. La fusion preserve le reste du fichier.
python3 -c 'import json,os;p=os.path.expanduser("~/.claude.json");d=json.load(open(p)) if os.path.exists(p) else {};d.setdefault("projects",{}).setdefault(os.path.expanduser("~/app"),{})["hasTrustDialogAccepted"]=True;json.dump(d,open(p,"w"))'
touch "$HOME/.plume-factory-ready"
echo "provision : bun $(bun --version), $(just --version), claude approuve"
"""
# La gate de setup : deterministe, zero token. Elle affirme, elle n'installe pas.
# $1 = le commit de reference, $2 = le moteur (podman : la porte doit REFUSER
# un hote hors liste — la preuve que la boite est fermee).
GATE = r"""
set -uo pipefail
export PATH="$HOME/.bun/bin:$HOME/.local/bin:/usr/local/bin:$PATH"
cd "$HOME/app" || { echo "KO ~/app absent — fill n'a pas tourne"; exit 1; }
test -f "$HOME/.plume-factory-ready" || { echo "KO sentinelle absente — setup n'est pas alle au bout"; exit 1; }
for t in uv pi claude bun just git sqlite3; do
command -v "$t" >/dev/null 2>&1 && echo "ok $t" || { echo "KO $t introuvable"; exit 1; }
done
head="$(git rev-parse HEAD)"
[ "$head" = "$1" ] && echo "ok HEAD = fiche ($head)" || { echo "KO HEAD $head != fiche $1"; exit 1; }
test -s .env && echo "ok .env present" || { echo "KO .env absent ou vide — fill n'a pas trouve OPENROUTER_API_KEY"; exit 1; }
curl -fsS --max-time 15 -o /dev/null https://openrouter.ai/api/v1/models && echo "ok porte : openrouter.ai joignable" || { echo "KO openrouter.ai injoignable depuis la boite (porte ? reseau ?)"; exit 1; }
if [ "$2" = "podman" ]; then
curl -fsS --max-time 10 -o /dev/null https://example.com/ 2>/dev/null && { echo "KO example.com joignable : la boite n'est PAS fermee"; exit 1; } || echo "ok porte : hote hors liste refuse"
fi
( cd apps/plume && bun test >/dev/null 2>&1 ) && echo "ok bun test" || { echo "KO bun test rouge dans la boite"; exit 1; }
"""
# ── outils ──────────────────────────────────────────────────────────────────
def now() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def record_path(run_id: str) -> Path:
return RUNS_DIR / f"{run_id}.json"
def load_record(run_id: str) -> dict:
path = record_path(run_id)
if not path.is_file():
die(f"aucune fiche pour {run_id} — `list` montre les runs connus")
return json.loads(path.read_text(encoding="utf-8"))
def save_record(record: dict) -> None:
RUNS_DIR.mkdir(parents=True, exist_ok=True)
record_path(record["run_id"]).write_text(
json.dumps(record, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
def new_record(run_id: str) -> dict:
"""La fiche : ce que les six phases partagent, et rien d'autre."""
return {"run_id": run_id, "created_at": now(), "backend": None, "box": None, "url": None,
"commit_sha": None, "pid": None, "closed_at": None}
def new_run_id(task: str) -> str:
"""Un id qui n'a jamais existe : <tache>-<AAAAMMJJ>-<6 hex>.
L'id devient le nom de la boite — et, chez exe.dev, un nom d'hote public :
minuscules, chiffres, tirets, et unique, sinon deux runs se disputent un nom."""
slug = re.sub(r"[^a-z0-9]+", "-", task.lower()).strip("-")[:30] or "run"
return f"{slug}-{datetime.now(timezone.utc):%Y%m%d}-{secrets.token_hex(3)}"
def keep(info: tarfile.TarInfo) -> tarfile.TarInfo | None:
"""Le filtre du chargement : None = ce fichier ne voyage pas."""
parts = Path(info.name).parts
if EXCLUDED_PARTS.intersection(parts):
return None
if any(p == ".env" or (p.startswith(".env.") and p != ".env.sample") for p in parts):
return None
return info
# ── les six phases ──────────────────────────────────────────────────────────
def create(name: str, flags: list[str]) -> str:
"""Hote seulement : la fiche, puis la boite (reseau, porte, conteneur), puis l'attente.
Rien sur la boite elle-meme."""
box = boxes.load()
run_id = name if re.search(r"-[0-9a-f]{6}$", name) else new_run_id(name)
if not re.fullmatch(r"[a-z0-9]([a-z0-9-]*[a-z0-9])?", run_id) or len(run_id) > 63:
die(f"{run_id!r} n'est pas un nom d'hote valide")
if record_path(run_id).exists():
die(f"la fiche {run_id} existe deja — choisissez un autre nom")
# 1. la fiche, AVANT la boite : un plantage plus loin laisse une poignee a teardown.
record = new_record(run_id)
record["backend"] = box.name
save_record(record)
print(f"run id : {run_id} ({box.name})")
# 2. la boite, par le port : reseau, porte et conteneur (Podman) ou VM (exe.dev).
record.update(box.create(run_id, flags))
save_record(record)
print(f"boite : {record['box']} → {record.get('url') or '(pas encore d URL)'}")
# 3. attendre la porte de la boite — bornee : une boite muette est un echec, pas une attente.
deadline = time.monotonic() + 90
while time.monotonic() < deadline:
if box.ok(record, "true"):
print("acces : pret")
break
time.sleep(2)
else:
die(f"la boite {run_id} n'a jamais repondu en 90 s — elle est gardee")
print(f"suite : uv run adws/sandbox_lifecycle.py fill {run_id}")
return run_id
def fill(run_id: str) -> None:
"""Le repo entier dans la boite, un commit de reference, puis LE seul secret qui traverse."""
record = load_record(run_id)
if not record.get("box"):
die(f"{run_id} n'a pas de boite — lancez create d'abord")
box = boxes.for_record(record)
# Un tar en flux, filtre par keep() : aucune dependance a l'outil tar de
# l'hote (le meme code sous Windows, macOS et Linux).
def write(stdin) -> None:
with tarfile.open(fileobj=stdin, mode="w|gz") as tar:
tar.add(".", arcname=".", filter=keep)
box.upload(record, write)
# Le commit de reference : tout ce qui arrive apres est l'oeuvre du run.
# teardown compare a ce sha pour rapatrier exactement le travail de la boite.
sha = box.exec(record, "bash -s", script=(
"set -e; cd app; git add -A; "
f"git {' '.join(GIT_IDENTITY)} commit -qm 'sandbox baseline' --allow-empty; "
"git rev-parse HEAD")).strip().splitlines()[-1]
record["commit_sha"] = sha
save_record(record)
print(f"repo : charge, reference {sha[:10]}")
# Le secret : lu sur l'hote, ecrit dans la boite par stdin — jamais dans
# argv, jamais affiche. Aujourd'hui c'est votre cle ; au ch. 23, une cle
# jetable et plafonnee prendra sa place ici, et seulement ici.
load_env()
key = os.environ.get("OPENROUTER_API_KEY", "")
if key:
box.exec(record, "umask 077 && cat > app/.env && chmod 600 app/.env",
script=f"OPENROUTER_API_KEY={key}\n")
print("secret : app/.env ecrit (0600) — valeur jamais affichee")
else:
print("secret : OPENROUTER_API_KEY absente de votre .env — aucun .env ecrit",
file=sys.stderr)
print(f"suite : uv run adws/sandbox_lifecycle.py setup {run_id}")
def setup(run_id: str, config: str) -> None:
"""Provisionner, attendre la sentinelle, puis la gate : a sec d'abord, un vrai run ensuite."""
record = load_record(run_id)
if not record.get("commit_sha"):
die(f"{run_id} n'a pas de commit de reference — lancez fill d'abord")
box = boxes.for_record(record)
print("1/3 provision")
print(" " + box.exec(record, "bash -s", script=PROVISION).strip().splitlines()[-1])
# La sentinelle : le seul signal de fin qui reste juste si la provision
# est un jour lancee en arriere-plan — et l'attente est bornee.
print("2/3 sentinelle")
for _ in range(30):
if box.ok(record, "test -f $HOME/.plume-factory-ready"):
break
time.sleep(2)
else:
die("la sentinelle n'est jamais apparue — la boite est gardee pour inspection")
print("3/3 gate")
out = box.exec(record, f"bash -s {shlex.quote(record['commit_sha'])} {box.name}",
script=GATE, check=False)
print("\n".join(" " + line for line in out.strip().splitlines()))
if "KO " in out:
die(f"gate rouge — la boite est gardee : uv run adws/sandbox_orch.py shell {run_id}"
f" ; demontage : uv run adws/sandbox_lifecycle.py teardown {run_id}")
# La seule assertion qui coute : un scout SOUS ROSTER, par le port du ch. 7,
# avec le roster que vous executerez — il prouve uv, pi, la cle, la porte
# et le registre d'un coup. Moins d'un centime, ~30 a 60 s.
cfg = f" --config {shlex.quote(config)}" if config else ""
proc = box.call(record, f"cd app && uv run adws/adw_scout.py "
f"'Cite le fichier de tests de apps/plume.'{cfg}")
if proc.returncode != 0:
print(proc.stderr.strip()[-600:], file=sys.stderr)
die("le scout a echoue dans la boite (cle ? roster ? porte ?) — la boite est gardee")
print(" ok scout sous roster : la chaine agent repond depuis la boite")
print(f"gate verte — {run_id} est prete")
print(f"suite : uv run adws/sandbox_lifecycle.py execute {run_id} \"<demande>\"")
def execute(run_id: str, prompt: str, config: str, adw: str) -> None:
"""Le travail, detache : nohup + redirections + stdin ferme, sinon la phase ne rend pas la main."""
record = load_record(run_id)
box = boxes.for_record(record)
cfg = f" --config {shlex.quote(config)}" if config else ""
command = (f"cd app && ( nohup uv run adws/{adw}.py {shlex.quote(prompt)}{cfg}"
" > run.log 2>&1 < /dev/null & echo $! )")
pid = box.exec(record, command).strip().splitlines()[-1]
if not pid.isdigit():
die(f"pas de pid rendu par la boite : {pid!r}")
record["pid"] = int(pid)
save_record(record)
print(f"execute : adw_{adw} lance dans {record['box']} (pid {pid}) — detache")
print(f"suite : uv run adws/sandbox_lifecycle.py observe {run_id}")
def observe(run_id: str) -> None:
"""Lire de l'exterieur : le run, sa trace, et Plume servie — sans jamais mettre la main dedans."""
record = load_record(run_id)
box = boxes.for_record(record)
pid = record.get("pid") or 0
script = f"""
set -u
export PATH="$HOME/.bun/bin:$HOME/.local/bin:/usr/local/bin:$PATH"
cd "$HOME/app"
if [ {pid} -gt 0 ] && kill -0 {pid} 2>/dev/null; then echo "run : en cours (pid {pid})"; else echo "run : termine, ou aucun execute lance"; fi
echo "--- run.log (fin)"; tail -n 12 run.log 2>/dev/null || echo "(pas de run.log)"
echo "--- runs (factory.db)"
sqlite3 -header -column adws/adw_data/factory.db "select adw_id, adw_name, status, round(cost_usd,3) as usd, started_at from runs order by started_at desc limit 5" 2>/dev/null || echo "(pas encore de trace)"
if ! curl -fs --max-time 2 -o /dev/null http://127.0.0.1:{APP_PORT}/; then
( cd apps/plume && nohup bun run server.ts > "$HOME/plume.log" 2>&1 < /dev/null & ) ; sleep 1
fi
"""
print(box.exec(record, "bash -s", script=script).rstrip())
url = box.url(record)
if url:
record["url"] = url
save_record(record)
hint = "privee — connectez-vous avec votre compte" if box.name == "exedev" else "par la porte, 127.0.0.1 seulement"
print(f"plume : {url or '(pas d URL)'} ({hint})")
def teardown(run_id: str) -> None:
"""Rapatrier la preuve, puis detruire. La seule phase qui detruit — et jamais enchainee."""
record = load_record(run_id)
box = boxes.for_record(record)
alive = bool(record.get("box") and box.exists(run_id))
if alive:
artifacts = RUNS_DIR / f"{run_id}-artifacts"
artifacts.mkdir(parents=True, exist_ok=True)
# Le travail du run = tout ce qui differe du commit de reference,
# nouveaux fichiers compris (specs/, app_docs/, le code de Plume).
patch = box.exec(record, "bash -s", script=(
"cd app && git add -A >/dev/null 2>&1; "
f"git diff --cached --binary {record['commit_sha']}"), binary=True)
(artifacts / "run.patch").write_bytes(patch)
(artifacts / "factory.db").write_bytes(
box.exec(record, "cat app/adws/adw_data/factory.db 2>/dev/null || true", binary=True))
(artifacts / "run.log").write_bytes(
box.exec(record, "cat app/run.log 2>/dev/null || true", binary=True))
print(f"preuve : {artifacts}/ (run.patch {len(patch)} octets, factory.db, run.log)")
# Podman : conteneur, porte et reseau ; exe.dev : la VM. Tout, d'un coup.
box.destroy(record)
print(f"boite : {record['box']} detruite")
else:
print(f"boite : {record.get('box') or '<aucune>'} deja absente — fiche fermee")
record["closed_at"] = now()
save_record(record)
print(f"fiche : {record_path(run_id)} fermee — `git apply {RUNS_DIR}/{run_id}-artifacts/run.patch`"
" rejoue le travail chez vous, a cote de votre branche")
def list_runs() -> None:
RUNS_DIR.mkdir(parents=True, exist_ok=True)
for path in sorted(RUNS_DIR.glob("*.json")):
r = json.loads(path.read_text(encoding="utf-8"))
state = "fermee" if r.get("closed_at") else ("pid " + str(r["pid"]) if r.get("pid") else "montee")
print(f"{r['run_id']:<40} {r.get('backend') or '-':<7} {state:<12} {r.get('url') or '-'}")
def mount(name: str, flags: list[str]) -> None:
"""La chaine : create → fill → setup → observe. S'arrete la, par construction."""
run_id = create(name, flags)
fill(run_id)
setup(run_id, "")
observe(run_id)
print(f"\nmontee : {run_id}\n execute : uv run adws/sandbox_lifecycle.py execute {run_id} \"<demande>\""
f"\n observe : uv run adws/sandbox_lifecycle.py observe {run_id}"
f"\n detruire: uv run adws/sandbox_lifecycle.py teardown {run_id}")
# ── la gate a sec ───────────────────────────────────────────────────────────
def selftest() -> int:
"""Zero reseau, zero conteneur, zero token : l'id, la fiche, le filtre de chargement, les deux moteurs."""
global RUNS_DIR
with tempfile.TemporaryDirectory() as tmp:
RUNS_DIR = Path(tmp)
rid = new_run_id("Ajoute un compteur !")
ok = re.fullmatch(r"ajoute-un-compteur-\d{8}-[0-9a-f]{6}", rid) is not None
save_record(new_record(rid))
ok &= load_record(rid)["run_id"] == rid and "backend" in load_record(rid)
names = ["apps/plume/server.ts", ".env.sample", "adws/adw_modules/harness.py",
"apps/plume/node_modules/x/index.js", ".env", "adws/adw_data/factory.db",
".env.local", "PLAN.md"]
kept = [n for n in names if keep(tarfile.TarInfo(n)) is not None]
ok &= kept == ["apps/plume/server.ts", ".env.sample",
"adws/adw_modules/harness.py", "PLAN.md"]
ok &= boxes.for_record({"backend": "podman"}).name == "podman"
ok &= boxes.for_record({"backend": "exedev"}).name == "exedev"
print(f"sandbox_lifecycle {'OK' if ok else 'KO'} — id, fiche, filtre : {len(kept)} fichiers"
" sur 8 voyagent, aucun secret ni runtime ; deux moteurs derriere le port")
return 0 if ok else 1
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="le cycle de vie d'une boite du hors-site")
parser.add_argument("--selftest", action="store_true", help="la gate a sec")
sub = parser.add_subparsers(dest="phase")
p = sub.add_parser("mount"); p.add_argument("name")
p.add_argument("flags", nargs=argparse.REMAINDER, help="les options du moteur (podman run / ssh exe.dev new)")
p = sub.add_parser("create"); p.add_argument("name")
p.add_argument("flags", nargs=argparse.REMAINDER)
sub.add_parser("fill").add_argument("run_id")
p = sub.add_parser("setup"); p.add_argument("run_id"); p.add_argument("--config", default="")
p = sub.add_parser("execute"); p.add_argument("run_id"); p.add_argument("prompt")
p.add_argument("--config", default=""); p.add_argument("--adw", default="adw_sdlc",
help="adw_sdlc (defaut), adw_plan, adw_scout…")
sub.add_parser("observe").add_argument("run_id")
sub.add_parser("teardown").add_argument("run_id")
sub.add_parser("list")
args = parser.parse_args()
if args.selftest:
raise SystemExit(selftest())
if args.phase == "mount":
mount(args.name, args.flags)
elif args.phase == "create":
create(args.name, args.flags)
elif args.phase == "fill":
fill(args.run_id)
elif args.phase == "setup":
setup(args.run_id, args.config)
elif args.phase == "execute":
execute(args.run_id, args.prompt, args.config, args.adw.removesuffix(".py"))
elif args.phase == "observe":
observe(args.run_id)
elif args.phase == "teardown":
teardown(args.run_id)
elif args.phase == "list":
list_runs()
else:
parser.print_help()
Pièce — just/sandbox/lifecycle.just
Le module du hors-site, monté par le justfile ci-dessous. Règle du chapitre 5 : un module
n’hérite de rien, working-directory remonte de deux crans, et le shell Windows se redéclare.
Toutes les recettes tiennent en une ligne, la logique vit dans le script. Les chapitres 23 et
24 y importeront keys.just et orch.just.
# 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"]
# 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 [--memory 4g --cpus 2]
mount NAME *FLAGS:
uv run adws/sandbox_lifecycle.py mount "$@"
# phase 1 — la fiche, puis la boite : reseau interne, porte, conteneur (hote seulement)
create NAME *FLAGS:
uv run adws/sandbox_lifecycle.py create "$@"
# phase 2 — le repo dans la boite, un commit de reference, le secret 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 — rapatrier la preuve puis detruire la boite (conteneur, porte, reseau) : une decision, jamais enchainee
teardown RUN:
uv run adws/sandbox_lifecycle.py teardown "$1"
# les fiches connues : montees, en cours, fermees
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"
Pièce — justfile
Cette version remplace celle du chapitre 19. Une seule ligne s’ajoute, le montage du module
sandbox, et rien d’autre ne bouge : vos recettes des chapitres 3 à 19 tournent telles quelles.
# justfile — la surface de commandes de plume-factory.
# `just` seul liste tout : c'est le panneau de commandes de l'usine.
# .env est chargé dans l'environnement des recettes (OPENROUTER_API_KEY au module 4).
set dotenv-load
# Les arguments de la ligne de commande deviennent $1, $2, "$@" dans les recettes.
set positional-arguments
# Windows : les recettes s'exécutent dans le bash de Git (installé avec git) — pas de WSL.
# Ce réglage est ignoré sur macOS/Linux ; adaptez le chemin si Git est installé ailleurs.
set windows-shell := ["C:/Program Files/Git/bin/bash.exe", "-cu"]
# la salle de contrôle de l'usine : just obs lanes, just obs runs… (ch. 19)
mod obs 'just/obs.just'
# le hors-site : just sandbox mount, execute, observe, teardown… (ch. 22)
mod sandbox 'just/sandbox/lifecycle.just'
# liste les recettes — sans elle, `just` nu exécuterait la première recette du fichier
default:
@just --list
# le préflight du poste de pilotage (ch. 4)
doctor:
uv run adws/doctor.py
# l'embryon du runner (ch. 3) — choisir le harnais : just hello claude
hello HARNESS="pi":
uv run adws/hello_factory.py --harness {{ HARNESS }}
# les tests du payload Plume (ch. 2) — cd et commande sur UNE ligne : chaque ligne a son shell
test:
cd apps/plume && bun test
# servir Plume en local sur le port 4500 (ch. 2)
serve:
cd apps/plume && bun run server.ts
# monter le poste : workspace herdr « plume-factory », Plume servie dans sa pane (ch. 6)
# La logique vit dans adws/fleet.py — la recette reste une ligne, comme la loi l'exige.
fleet:
uv run adws/fleet.py
# l'état de la flotte : chaque pane et son agent_status
fleet-status:
herdr pane list
# fermer le workspace de l'usine : just fleet-down w1
fleet-down WS:
herdr workspace close {{ WS }}
La gate du TP
Huit commandes, une par ligne, depuis la racine de plume-factory : les trois premières à
sec, les suivantes avec Podman (ou un compte exe.dev). La quatrième construit l’image de base du
chapitre 21 si vous ne l’avez pas encore (une fois, ~2 min, et mount refuse de démarrer sans
elle, inutile sur exe.dev) :
uv run adws/sandbox_box.py
uv run adws/sandbox_gate.py --selftest
uv run adws/sandbox_lifecycle.py --selftest
just sandbox image
just sandbox mount plume
just sandbox egress plume-<la-date>-<6 hex>
just sandbox execute plume-<la-date>-<6 hex> "Ajoute un compteur de caractères à Plume"
just sandbox teardown plume-<la-date>-<6 hex>
Attendu : sandbox_box OK — deux adaptateurs derriere un port…, sandbox_gate OK — … hote hors liste refuse (403), resolution locale, relais TCP intact, puis sandbox_lifecycle OK — … deux moteurs derriere le port (zéro réseau, zéro conteneur, zéro token). La cinquième déroule les quatre phases jusqu’à
gate verte, avec la ligne provision : bun …, just …, claude approuve et les deux lignes
ok porte : openrouter.ai joignable et ok porte : hote hors liste refuse, puis l’URL de
Plume sur 127.0.0.1 : moins de vingt secondes, moins
d’un centime (le scout de la gate). La sixième montre le journal de la porte : des
tunnel, quelques refuse. La septième est facultative et coûte le prix du run, quelques
dizaines de centimes sur le roster par défaut, ~5 centimes avec --config adws/adw_config/eco.config.yaml. La dernière rapatrie la preuve sous adws/adw_data/sandbox/
et détruit conteneur, porte et réseau, et podman ps -a ne les montre plus. Option exe.dev : les
mêmes commandes, egress en moins (la VM n’a pas de porte), moins d’une minute pour le
montage.