Sandboxes & scale Chapitre 22 / 42

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.py dit 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’appelle podman ni ssh directement, comme aucun ADW n’appelle pi hors du port du chapitre 7. SANDBOX_BACKEND dans .env choisit l’adaptateur d’une boîte neuve, la fiche de run nomme celui d’une boîte montée, quel que soit votre .env du 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 que CONNECT (le tunnel TLS) vers openrouter.ai, pypi.org, files.pythonhosted.org et registry.npmjs.org. Tout le reste reçoit un 403 immédiat et une ligne dans podman 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épond NXDOMAIN pour 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), puis SANDBOX_DNS (1.1.1.1 par défaut).
  • Le plan de contrôle et le plan de données sont deux verbes. podman network|create|run| rm cré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 que ssh <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 list les montre, teardown les 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

ObjetCommande hôteRôleChez exe.dev
Réseau interne plume-net-<run>podman network create --internalaucune route dehors ; DNS entre membresle réseau de la VM (ouvert)
Porte plume-gate-<run>podman create … --network interne --network podman -p 127.0.0.1::4500proxy CONNECT sur liste + relais Plume + journalle proxy HTTPS d’exe.dev (share port)
Conteneur plume-box-<run>podman run -d … --cap-drop ALL --pids-limit 512 sleep infinityl’usine entière, HTTPS_PROXY → la portela VM elle-même
Agir dedanspodman exec -i … bash -cle plan de donnéesssh <vm> "…"
Tout détruirepodman rm -f ×2, podman network rmdestroy du portssh 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 run est lisible par tout processus de votre machine dans podman inspect, reste dans l’historique de votre shell, et chez exe.dev n’atteint même pas les commandes lancées par ssh (elle vit dans le profil des shells interactifs). Le port du chapitre 15 lit .env dans le repo, quel que soit le shell : c’est là que fill é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.
  • fill charge l’arbre exact, puis fige une référence. Un tar en flux, filtré par le code (jamais node_modules, jamais adw_data, jamais .env, mais bien .git et .env.sample), écrit sur le stdin de podman 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 donc hasTrustDialogAccepted pour ~/app dans ~/.claude.json, sans quoi une phase sur le harnais claude sort en code 1 (« this workspace has not been trusted »), gate déterministe verte et scout rouge.
  • setup provisionne 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, .env non vide, la porte laisse passer la passerelle et refuse un hôte hors liste, bun test vert. Puis une seule assertion payante, un scout sous roster, par le port du chapitre 7, qui prouve uv, pi, la clé, la porte et le registre d’un coup, pour moins d’un centime.
  • execute est détaché ou n’est pas. nohup, redirections, < /dev/null : les trois sont nécessaires, sinon podman exec, comme ssh, 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. observe lit run.log et la table runs du module 5 depuis l’hôte, et sert Plume par le relais de la porte.
  • teardown rapatrie avant de détruire, et n’est jamais enchaîné. Un run.patch (le diff binaire depuis le commit de référence, nouveaux fichiers compris), factory.db, run.log, puis destroy : conteneur, porte, réseau. La chaîne mount s’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

PhaseCôtéProduitSi elle échoue
createcode, hôte seulla fiche ; réseau, porte, conteneur ; l’URLfiche gardée, boîte gardée
fillcodele repo, un commit de référence, .envboîte gardée pour inspection
setupcode (+ un scout borné)outils, sentinelle, gate verte — porte prouvéeboîte gardée, gate rouge lisible
executeagent dans un runnerun pid, run.log, la tracele runner décide, comme chez vous
observecodelecture externe, Plume servie par la porterien n’est modifié
teardowncoderun.patch, factory.db, boîte détruiterien 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 apportezéro euro, une seconde, réseau fermé par la porteun 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ûtePersonal : 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 perdezl’échelle hors de votre machinela 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 basculerpar défaut, et pour tout le livrequand 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, ou KO 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.


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