La frontière des credentials & les clés provisionnées
La boîte ne connaît que ce qu'elle peut dépenser : une clé de gestion qui ne quitte jamais l'hôte, une clé jetable par run — plafonnée, datée, révoquée au démontage. La pièce du jour rend la frontière des credentials exécutable.
Hier, vous avez monté une boîte fermée, dont la porte ne laisse sortir que la passerelle, et
fill y a écrit un secret : votre clé OpenRouter, celle qui paie tout ce que vous faites
depuis le chapitre 15. Relisez la scène : la porte garantit que cette clé ne part que vers
openrouter.ai, mais elle n’a pas de plafond propre, elle survit à la boîte, et un agent qui
s’emballe à trois heures du matin la dépense exactement comme vous. Le chapitre 21 avait posé
la troisième propriété de l’isolation, bornée par les credentials, et le chapitre 22 avait
promis qu’une seule ligne de fill
changerait de source. C’est aujourd’hui. À la fin de ce chapitre, chaque boîte recevra une clé
qui n’existait pas avant elle et n’existera plus après : plafonnée en dollars, datée
d’expiration, révoquée au démontage avec preuve. La clé capable d’en fabriquer d’autres
n’aura jamais quitté votre machine. La pièce du jour est adws/sandbox_keys.py et son module
keys.just, et le cycle de vie d’hier apprend à s’en servir.
La clé de provisioning ne quitte jamais l’hôte
L’idée en une phrase
La frontière des credentials sépare deux capacités. La clé de gestion (OpenRouter
l’appelle aujourd’hui Management API key, hier Provisioning, et le livre garde le nom
OPENROUTER_PROVISIONING_KEY) sait fabriquer et révoquer des clés mais ne sait pas faire
d’inférence, et elle reste sur l’hôte. La clé jetable d’un run sait dépenser, jusqu’à son
plafond, et c’est la seule qui traverse. Cette frontière est une pièce côté déterministe :
ce qu’une boîte ne peut pas faire, ce sont les clés qu’elle n’a pas.
Points clés
- Deux clés, deux rôles, aucun recouvrement. La clé de gestion ne peut pas appeler un modèle : une boîte qui la volerait ne pourrait qu’en fabriquer d’autres, et c’est exactement pourquoi elle ne traverse jamais. Inversement, une clé jetable ne peut pas en fabriquer une autre : sans clé de gestion, sans socket Podman ni compte exe.dev, une boîte ne monte pas de boîte. Le niveau d’imbrication unique du chapitre 21 tient à ces absences, pas à un fichier effacé.
- La clé de gestion est lue, jamais montrée, jamais transmise. Elle vit dans
.env(ignoré par git depuis le chapitre 1), le préflight du chapitre 21 constate sa présence sans afficher sa valeur, etsandbox_keys.pyl’envoie à une seule destination : l’API de gestion, en en-tête, depuis l’hôte. Aucune phase ne la passe àpodman execni àssh, aucunprintne la formate. - Le secret et ses métadonnées suivent deux chemins. La réponse d’un mint contient la clé
en clair, une seule fois, jamais récupérable ensuite. Le script l’écrit dans un fichier
<run>.keycréé en 0600, et n’inscrit dans la fiche de run que le hash, le plafond et l’expiration. La fiche est greppable et affichée parlist, la clé n’y entre jamais. - Elle est la seule clé longue durée du système. Si elle fuit, changez-la sur le tableau de
bord : la fabrication et la révocation meurent avec elle, mais les clés jetables déjà émises
continuent de dépenser jusqu’à leur plafond, leur expiration ou un
reap, d’où les deux bornes du second sous-thème.
Exemple concret
Suivez un run du chapitre 22, version d’aujourd’hui. create vérifie que la clé de gestion est
présente avant de créer quoi que ce soit, écrit la fiche, monte la boîte (réseau, porte,
conteneur), attend qu’elle réponde, puis fabrique une clé sbx-plume-20260905-3f9a1c
plafonnée à 5 $ et valable 24 h : un appel HTTP, moins d’une seconde, zéro token. fill
pousse cette clé, et elle seule, dans app/.env. Le SDLC tourne : quelques dizaines de
centimes. teardown lit la dépense sur la clé (0,42 $, disons), rapatrie la preuve,
révoque la clé, vérifie qu’elle a disparu de la liste, détruit la boîte. Bilan : la boîte n’a
jamais rien connu qui vaille plus de 5 $ ni pu parler à autre chose que la passerelle, et votre
compte n’a jamais rien connu de la boîte. Comparez au chapitre 22 : même run, même coût, mais
hier le pire cas était votre solde entier.
Ce que chaque clé peut faire
| Capacité | Clé de gestion (hôte) | Clé jetable (boîte) | Votre clé personnelle |
|---|---|---|---|
| Appeler un modèle | non — refusé par l’API | oui, jusqu’au plafond | oui, sans plafond propre |
| Fabriquer une clé | oui | non | non |
| Révoquer une clé | oui | non | non |
| Lire sa propre dépense | — | oui (GET /key) | oui |
| Traverse vers la boîte | jamais | oui, par fill, en 0600 | plus jamais depuis ce chapitre |
| Peut sortir de la boîte vers… | — | openrouter.ai seulement (la porte, ch. 22) | — |
Config — .env.sample, la forme des deux clés
Cette version remplace celle du chapitre 15. Le gabarit reste vide de tout secret, le
préflight du chapitre 21 y veille, et il documente désormais la frontière : quelle clé vit où, et
quel moteur monte les boîtes (le seul champ qui porte une valeur, parce que ce n’est pas un
secret). Copiez la nouvelle ligne dans votre .env après avoir créé la clé de gestion sur
openrouter.ai/settings/management-keys.
# .env.sample — le gabarit des secrets de l'usine. Copiez en .env, remplissez.
# .env n'est JAMAIS commite (couvert par le .gitignore du ch. 1) ; ce gabarit, si.
# ── Inference : vos runs sur CETTE machine (ch. 15). Dans une boite, fill
# remplace cette valeur par une cle JETABLE, plafonnee et datee (ch. 23).
OPENROUTER_API_KEY=
# ── HOTE SEULEMENT — jamais montee, jamais poussee dans une boite (ch. 23).
# La cle de gestion fabrique et revoque les cles jetables. Elle ne peut pas
# faire d'inference. Se cree sur openrouter.ai/settings/management-keys.
OPENROUTER_PROVISIONING_KEY=
# ── Le moteur de boites du hors-site (ch. 22) : podman (defaut, gratuit,
# local, ferme par une porte) ou exedev (une VM par boite, compte payant).
# Pas un secret : la seule valeur que ce gabarit a le droit de porter.
SANDBOX_BACKEND=podman
Piège courant : « je mets la clé de gestion dans la boîte, comme ça l’orchestrateur en boîte pourra tout faire » est inexact. C’est précisément la capacité que le module retire à la boîte. Une boîte qui fabrique des clés peut monter des boîtes, qui montent des boîtes : l’imbrication n’a plus de borne, et la dépense non plus. L’orchestrateur en boîte du chapitre 24 travaillera avec la clé jetable de son run, et rien d’autre.
Clés éphémères : plafond, création, révocation
L’idée en une phrase
Une clé jetable a trois bornes, posées par le code déterministe et jamais par l’agent : un
plafond en dollars (le pire cas d’un run qui déraille), une expiration (la ceinture, si
le démontage ne tourne jamais) et une révocation explicite au démontage, dont la preuve est
l’absence de la clé dans la liste, pas la réponse du DELETE.
Points clés
- L’ordre de
createest le design : fiche → boîte → accès → clé. Un mint qui échoue après la boîte laisse une boîte orpheline, visible danspodman psoussh exe.dev ls, gratuite. Une boîte qui échoue après un mint laisserait une clé orpheline, invisible et capable de dépenser. La clé vient donc en dernier, etcreaterefuse de démarrer si la clé de gestion est absente. - L’ordre de
teardownest le miroir : dépense → preuve → clé → boîte → fiche. La dépense se lit avant la révocation, car le chiffre est irrécupérable après, et c’est lui qui rendra un best-of-N comparable au chapitre 25. Un échec de révocation arrête tout : la boîte reste debout, la fiche ouverte, la clé vivante reste en vue. - La liste fait foi, pas le
DELETE. Juste après une révocation réussie, la clé morte répond encore àGET /keyun court moment. La gate demande doncGET /keys, page par page, cent clés à la fois, et exige l’absence du hash.404sur unDELETEse lit « déjà absente » : le démontage est idempotent, un run peut mourir n’importe où. - Le préfixe
sbx-est tout le modèle de sécurité dereap. Vos clés personnelles n’en portent pas, et en supprimer une est irréversible. Le filet ne considère, ne liste et ne révoque que des clés préfixées, et réaffirme le préfixe au moment précis de la suppression. Une clé est orpheline si sa fiche est fermée, absente, ou si sa boîte n’existe plus, ce que le moteur nommé par la fiche répond via le port du chapitre 22. Un moteur injoignable ne rend personne orphelin. Une fiche ouverte sans boîte est un essai en cours, jamais candidate. - Le plafond n’est pas le coût, c’est le pire cas. 5 $ par défaut pour un run qui coûte
quelques dizaines de centimes : un facteur dix de marge, et une perte bornée si un agent
boucle. Au moment d’écrire, l’API accepte aussi
expires_at. Le livre s’en sert comme ceinture (24 h) et garde la révocation comme règle, car une clé expirée reste listée.
Exemple concret
Un mercredi soir, vous lancez trois boîtes best-of-N (chapitre 25) et fermez le portable. Le
jeudi, une seule a terminé, les deux autres ont planté à setup, boîtes debout, clés vivantes.
just sandbox teardown sur la première : dépense 0,38 $ relevée, preuve rapatriée, clé
révoquée, gate verte, boîte détruite. Pour les deux autres, vous décidez de faire un podman rm -f à la main sans démontage, et vous oubliez leurs clés. Vendredi : just sandbox reap liste
deux clés sbx-* dont la « boîte a disparu », 0,00 $ chacune, et attend votre --yes. Sans le filet,
elles auraient dépensé jusqu’à 5 $ chacune pendant 24 h au plus grâce à l’expiration, et sans
elle, indéfiniment. Coût du filet : deux appels HTTP, zéro token, deux secondes.
Les trois bornes d’une clé jetable
| Borne | Posée par | Ce qu’elle arrête | Si elle manque |
|---|---|---|---|
Plafond (limit, 5 $) | create, avant fill | un agent qui boucle | la perte = votre solde |
Expiration (expires_at, 24 h) | create | un démontage qui n’a jamais tourné | la clé survit à la boîte |
| La porte (ch. 22) | create, Podman | toute sortie hors openrouter.ai | la clé peut être envoyée n’importe où |
Révocation (DELETE + gate sur la liste) | teardown ou reap | toute dépense après la preuve | une clé vivante hors de vue |
Commande — la surface just sandbox côté clés
Une seule version suffit ici : la frontière vit entièrement sur l’hôte, en amont des deux
harnais, et le port du chapitre 7 lit OPENROUTER_API_KEY dans la boîte sans savoir qu’elle est
jetable. Rien ne change pour pi ni pour Claude Code.
# une cle seule, sans boite : la gate du jour, zero token
just sandbox mint essai --limit 1 --ttl 2
just sandbox keys # nom, plafond, depense, expiration
just sandbox revoke essai-20260905-9c2e4f
# le cycle de vie complet, cle comprise (ch. 22 + ch. 23)
just sandbox mount plume --limit 5
just sandbox teardown plume-20260905-3f9a1c
# le filet, a lancer en debut de session : a sec, puis --yes
just sandbox reap
just sandbox reap --yes
Piège courant : « le
DELETEa répondu 200, la clé est morte » est inexact. La réponse duDELETEdit que la demande a été acceptée, pas que la clé a cessé d’exister partout. La seule preuve est l’absence du hash dansGET /keys, et c’est sur elle queteardownetreapposent leur gate. UnDELETEsans gate est unprint("fini")du chapitre 3 : une affirmation, pas une vérification.
Fil rouge — la pièce posée aujourd’hui
Sur le plan de l’usine, la zone just/sandbox/ reçoit sa deuxième pièce, keys.just,
importée par le lifecycle.just d’hier, avec sa logique dans adws/sandbox_keys.py, et le
cycle de vie du chapitre 22 évolue : create fabrique la clé, fill la pousse, teardown la
révoque. La loi ne bouge pas, l’agent propose et le code dispose, mais ici le code dispose aussi
de l’argent : le plafond, l’expiration et la révocation sont trois décisions déterministes
prises avant et après le run, jamais pendant, jamais par l’agent. La couture ne s’est pas
déplacée, ce qui la traverse a changé : hier votre clé, aujourd’hui une clé qui ne vaut que
son plafond. La fiche de run porte désormais key_hash, key_limit, key_expires_at et
spend_usd, soit la dépense à côté du résultat, ce que le chapitre 25 comparera. Avec la porte
d’hier, la frontière a désormais deux faces : ce que la boîte peut dépenser (la clé) et à
qui elle peut parler (la liste). À l’usage : trois appels HTTP par run, zéro token,
quelques secondes. En retour, le pire cas d’un agent qui déraille passe de votre solde à
cinq dollars.
Travaux pratiques — la pièce du jour
Une pièce complète à poser dans le repo compagnon plume-factory, qui devient, chapitre après
chapitre, votre usine logicielle agentique. Aujourd’hui : la frontière des credentials, son
module just, et le cycle de vie d’hier, script et module, qui apprend à s’en servir.
Prérequis : une clé de gestion OpenRouter, créée à la main sur
openrouter.ai/settings/management-keys (gratuite, et incapable de faire de l’inférence), collée
dans .env sous OPENROUTER_PROVISIONING_KEY. Mettez aussi à jour .env.sample avec la version
de la fiche. Sans clé de gestion, tout le chapitre se lit à sec et les deux premières gates
tournent quand même. Les gates de clés n’ont besoin d’aucune boîte.
Pièce — adws/sandbox_keys.py
La frontière, côté déterministe et côté hôte : fabriquer, mesurer, révoquer, moissonner. Elle
possède désormais la fiche de run (le cycle de vie l’importe d’ici) et s’appuie sur le
.gitignore du chapitre 1 (la clé jetable vit sous adw_data/), sur le contrat .env du
chapitre 15, et sur le port « boîte » du chapitre 22 pour savoir si la boîte d’une clé existe
encore. Bibliothèque standard uniquement, comme hier : un démontage ne doit jamais attendre une
chaîne d’outils.
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# ///
"""sandbox_keys — la frontiere des credentials : une cle jetable par run.
Deux cles, deux roles, deux cotes de la frontiere :
- OPENROUTER_PROVISIONING_KEY : la cle DE GESTION (OpenRouter l'appelle
« Management API key »). Elle fabrique et revoque des cles. Elle ne
peut PAS faire d'inference. Elle ne quitte JAMAIS l'hote : lue ici,
jamais affichee, jamais ecrite dans une boite, jamais passee sur ssh.
- la cle JETABLE du run : fabriquee ici, plafonnee en dollars, datee
d'expiration, ecrite en 0600 sous adw_data/, poussee dans la boite par
`fill`, revoquee au demontage. C'est la seule cle qui traverse.
Ce que la boite ne peut pas faire = les cles qu'elle n'a pas : sans cle de
gestion, une boite ne fabrique pas de cle ; sans acces au moteur de boites
(le socket Podman de l'hote, le compte exe.dev), elle ne monte pas de boite.
Un seul niveau d'imbrication, garanti par construction.
Bibliotheque standard uniquement (urllib) : ce script tourne sur l'hote, et
un demontage ne doit jamais attendre une chaine d'outils.
uv run adws/sandbox_keys.py mint <run-ou-nom> [--limit 5] [--ttl 24]
uv run adws/sandbox_keys.py spend <run>
uv run adws/sandbox_keys.py revoke <run>
uv run adws/sandbox_keys.py list
uv run adws/sandbox_keys.py reap [--yes]
uv run adws/sandbox_keys.py --selftest # la gate a sec, zero reseau
"""
from __future__ import annotations
import argparse
import json
import os
import re
import secrets
import sys
import tempfile
import urllib.error
import urllib.request
from datetime import datetime, timedelta, timezone
from pathlib import Path
API = "https://openrouter.ai/api/v1"
RUNS_DIR = Path("adws/adw_data/sandbox") # couvert par le .gitignore du ch. 1
PREFIX = "sbx-" # TOUT le modele de securite de reap : jamais de mint sans lui
DEFAULT_LIMIT = 5.0 # dollars : le pire cas d'un run qui deraille, pas son cout normal
DEFAULT_TTL_HOURS = 24 # la ceinture : la cle meurt seule si teardown ne tourne jamais
# ── la fiche de run (partagee avec sandbox_lifecycle.py, qui importe d'ici) ──
def now() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def die(message: str, code: int = 1) -> None:
print(f"keys : {message}", file=sys.stderr)
sys.exit(code)
def load_env(path: Path = Path(".env")) -> None:
"""Le meme contrat que le port du ch. 15 : .env sans jamais ecraser l'environnement."""
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 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 forme complete de la fiche depuis le ch. 23 : les champs cle en plus."""
return {"run_id": run_id, "created_at": now(), "backend": None, "box": None, "url": None,
"commit_sha": None, "pid": None,
"key_hash": None, "key_limit": None, "key_expires_at": None,
"spend_usd": None, "closed_at": None}
def new_run_id(task: str) -> str:
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)}"
# ── la frontiere ────────────────────────────────────────────────────────────
def provisioning_key() -> str:
"""La cle de gestion : presence verifiee, valeur jamais affichee ni transmise."""
load_env()
key = os.environ.get("OPENROUTER_PROVISIONING_KEY", "")
if not key:
die("OPENROUTER_PROVISIONING_KEY absente de .env — la cle de gestion (hote seulement)"
" se cree sur openrouter.ai/settings/management-keys")
return key
def api(method: str, path: str, token: str, body: dict | None = None) -> tuple[int, dict]:
"""Un appel a l'API de gestion : (code HTTP, JSON). Aucune dependance hors stdlib."""
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(f"{API}{path}", data=data, method=method,
headers={"Authorization": f"Bearer {token}",
"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=30) as resp:
raw = resp.read()
return resp.status, (json.loads(raw) if raw else {})
except urllib.error.HTTPError as err:
raw = err.read()
try:
return err.code, json.loads(raw)
except json.JSONDecodeError:
return err.code, {"error": {"message": raw.decode(errors="replace")[:200]}}
except (urllib.error.URLError, TimeoutError) as err:
die(f"{method} {path} : reseau injoignable ({err})")
return 0, {}
def key_file(run_id: str) -> Path:
return RUNS_DIR / f"{run_id}.key"
def read_runtime_key(run_id: str) -> str | None:
"""La cle jetable du run, si mint l'a ecrite. Jamais affichee par personne."""
path = key_file(run_id)
return path.read_text(encoding="utf-8").strip() if path.is_file() else None
def write_secret(path: Path, secret: str) -> None:
"""Ecrit en 0600 des la creation : jamais un instant lisible par d'autres."""
path.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(secret + "\n")
def shred(run_id: str) -> None:
"""Ecraser puis supprimer — portable (pas de `shred` sous Windows ni macOS)."""
path = key_file(run_id)
if path.is_file():
size = path.stat().st_size
with open(path, "r+b") as handle:
handle.write(b"\0" * size)
path.unlink()
def mint_request(run_id: str, limit: float, ttl_hours: int) -> dict:
"""Le corps de la demande : nom prefixe, plafond en dollars, expiration UTC a la seconde."""
if not run_id or not re.fullmatch(r"[a-z0-9-]+", run_id):
die(f"{run_id!r} n'est pas un id de run valide")
if limit <= 0 or ttl_hours <= 0:
die("--limit et --ttl doivent etre strictement positifs")
expires = datetime.now(timezone.utc) + timedelta(hours=ttl_hours)
return {"name": f"{PREFIX}{run_id}", "limit": limit,
"expires_at": expires.strftime("%Y-%m-%dT%H:%M:%SZ")}
def mint_response(run_id: str, payload: dict) -> dict:
"""Separe le secret (vers le fichier 0600) des metadonnees (vers la fiche).
La fiche est greppable et affichee dans les rapports : la cle n'y entre jamais."""
secret = payload.get("key")
data = payload.get("data") or {}
if not secret or not data.get("hash"):
die("reponse de mint sans cle ni hash — rien n'a ete ecrit")
write_secret(key_file(run_id), secret)
return {"key_hash": data["hash"], "key_limit": data.get("limit"),
"key_expires_at": data.get("expires_at")}
def mint(run_id: str, limit: float, ttl_hours: int) -> dict:
"""Fabriquer la cle jetable d'un run. Fiche d'abord ; la cle n'y est jamais ecrite."""
token = provisioning_key()
if not record_path(run_id).is_file():
run_id = run_id if re.search(r"-[0-9a-f]{6}$", run_id) else new_run_id(run_id)
save_record(new_record(run_id))
record = load_record(run_id)
if record.get("key_hash") and read_runtime_key(run_id):
die(f"{run_id} a deja une cle ({record['key_hash'][:12]}…) — revoquez-la d'abord")
code, payload = api("POST", "/keys", token, mint_request(run_id, limit, ttl_hours))
if code not in (200, 201):
# Un mint rate n'a rendu aucun secret : le message est affichable.
die(f"mint refuse (HTTP {code}) : {payload.get('error', {}).get('message', payload)}")
record.update(mint_response(run_id, payload))
save_record(record)
print(f"cle : {record['key_hash'][:12]}… plafond {record['key_limit']} $,"
f" expire {record['key_expires_at']} ({key_file(run_id)}, 0600)")
return record
def list_keys(token: str) -> list[dict]:
"""La LISTE, page par page (100 par page, `offset`) : la seule vue qui fait foi."""
keys: list[dict] = []
offset = 0
while True:
code, payload = api("GET", f"/keys?offset={offset}", token)
if code != 200:
die(f"GET /keys refuse (HTTP {code})")
page = payload.get("data") or []
keys.extend(page)
if len(page) < 100:
return keys
offset += 100
def spend(run_id: str, key_hash: str | None = None) -> float | None:
"""La depense d'un run, lue AVANT que la cle meure — irrecuperable apres DELETE.
D'abord par la cle elle-meme (GET /key : un secret de boite, pas de gestion),
sinon par la liste de gestion, qui porte `usage` par hash."""
runtime = read_runtime_key(run_id)
if runtime:
code, payload = api("GET", "/key", runtime)
if code == 200:
return float((payload.get("data") or {}).get("usage") or 0.0)
if key_hash:
for key in list_keys(provisioning_key()):
if key.get("hash") == key_hash:
return float(key.get("usage") or 0.0)
return None
def revoke_hash(key_hash: str, name: str = "") -> str:
"""DELETE puis gate sur la LISTE : juste apres un DELETE reussi, GET /key repond
encore 200 pour la cle morte — seule la liste fait foi."""
if name and not name.startswith(PREFIX):
die(f"refus de revoquer {name!r} : pas de prefixe {PREFIX} — une cle personnelle"
" ne passe jamais par ici")
token = provisioning_key()
code, payload = api("DELETE", f"/keys/{key_hash}", token)
if code == 404:
verdict = "deja absente"
elif 200 <= code < 300:
verdict = "revoquee"
else:
die(f"DELETE /keys/{key_hash[:12]}… a rendu {code} — la cle est peut-etre VIVANTE :"
f" {payload.get('error', {}).get('message', '')}")
if any(k.get("hash") == key_hash for k in list_keys(token)):
die(f"gate rouge : {key_hash[:12]}… figure encore dans la liste — la cle est VIVANTE")
return verdict
def revoke(run_id: str) -> None:
"""Depense relevee, cle revoquee (gate), fichier ecrase, fiche fermee si aucune boite."""
record = load_record(run_id)
key_hash = record.get("key_hash")
if not key_hash:
print(f"cle : aucune fabriquee pour {run_id} — rien a revoquer")
else:
usd = spend(run_id, key_hash)
if usd is not None:
record["spend_usd"] = usd
print(f"depense: {usd:.4f} $ relevee avant revocation")
verdict = revoke_hash(key_hash, f"{PREFIX}{run_id}")
shred(run_id)
print(f"cle : {key_hash[:12]}… {verdict} — gate verte : absente de la liste")
if record.get("box") and not record.get("closed_at"):
print(f"boite : {record['box']} tourne encore sans cle — `teardown {run_id}` la detruit")
else:
record["closed_at"] = record.get("closed_at") or now()
print(f"fiche : {record_path(run_id)} fermee")
save_record(record)
def box_alive(record: dict) -> bool | None:
"""La boite d'une fiche est-elle vivante ? Par le port du ch. 22, avec le
moteur que la fiche nomme — None si ce moteur est injoignable (jamais
« morte » par defaut : un moteur muet ne fait pas une orpheline)."""
import sandbox_box as boxes # meme dossier : uv place adws/ en tete
names = boxes.for_record(record).names()
return None if names is None else record["run_id"] in names
def orphans(keys: list[dict], alive=box_alive) -> list[tuple[dict, str]]:
"""Le filet : une cle sbx- dont le run est ferme, ou dont la boite n'existe plus.
Une cle sans prefixe n'est jamais consideree, jamais listee, jamais touchee.
alive(record) → True | False | None (moteur injoignable : pas candidate)."""
found = []
for key in keys:
name = key.get("name") or ""
if not name.startswith(PREFIX):
continue
run_id = name[len(PREFIX):]
record = json.loads(record_path(run_id).read_text(encoding="utf-8")) \
if record_path(run_id).is_file() else None
if record is None:
found.append((key, "aucune fiche sur cet hote"))
elif record.get("closed_at"):
found.append((key, f"fiche fermee le {record['closed_at']}"))
elif not record.get("box"):
continue # une cle seule, sans boite : un essai en cours
elif alive(record) is False:
found.append((key, f"boite {record['box']} disparue ({record.get('backend') or '?'})"))
return found
def reap(apply: bool) -> None:
token = provisioning_key()
found = orphans(list_keys(token))
if not found:
print(f"reap : aucune cle {PREFIX}* orpheline")
return
print(f"reap : {len(found)} cle(s) {PREFIX}* orpheline(s)")
for key, why in found:
print(f" {key['name']:<44} {float(key.get('usage') or 0):>8.4f} $ {why}")
if not apply:
print("a sec — rien revoque. Pour agir : just sandbox reap --yes")
return
for key, _ in found:
verdict = revoke_hash(key["hash"], key["name"])
print(f" {key['name']:<44} {verdict}")
shred(key["name"][len(PREFIX):])
print("gate verte : chaque cle moissonnee est absente de la liste")
def show() -> None:
keys = [k for k in list_keys(provisioning_key()) if (k.get("name") or "").startswith(PREFIX)]
if not keys:
print(f"aucune cle {PREFIX}* sur ce compte")
return
print(f"{'nom':<44} {'plafond':>8} {'depense':>9} expire")
for key in keys:
print(f"{key['name']:<44} {float(key.get('limit') or 0):>7.2f}$"
f" {float(key.get('usage') or 0):>8.4f}$ {key.get('expires_at') or '-'}")
# ── la gate a sec ───────────────────────────────────────────────────────────
def selftest() -> int:
"""Zero reseau, zero token : le prefixe, la separation secret / fiche, l'ecrasement."""
global RUNS_DIR
with tempfile.TemporaryDirectory() as tmp:
RUNS_DIR = Path(tmp)
rid = "essai-20260902-0a1b2c"
body = mint_request(rid, 1.0, 2)
ok = body["name"] == f"{PREFIX}{rid}" and body["limit"] == 1.0 \
and body["expires_at"].endswith("Z")
fake = {"data": {"hash": "f01d" * 16, "limit": 1.0, "expires_at": body["expires_at"]},
"key": "sk-or-v1-secret-de-test"}
record = new_record(rid)
record.update(mint_response(rid, fake))
save_record(record)
fiche = record_path(rid).read_text(encoding="utf-8")
ok &= "sk-or-v1" not in fiche and record["key_hash"] == fake["data"]["hash"]
ok &= read_runtime_key(rid) == "sk-or-v1-secret-de-test"
if os.name != "nt":
ok &= (key_file(rid).stat().st_mode & 0o777) == 0o600
shred(rid)
ok &= read_runtime_key(rid) is None
# Une fiche ouverte sans boite est un essai en cours : jamais candidate.
ok &= orphans([{"name": f"{PREFIX}{rid}", "hash": "h" * 8}], alive=lambda r: False) == []
# Une fiche ouverte AVEC boite : candidate seulement si le moteur dit « disparue ».
record["box"] = "plume-box-" + rid
save_record(record)
ok &= orphans([{"name": f"{PREFIX}{rid}", "hash": "h" * 8}], alive=lambda r: None) == []
ok &= [why for _, why in orphans([{"name": f"{PREFIX}{rid}", "hash": "h" * 8}], alive=lambda r: False)] \
== [f"boite plume-box-{rid} disparue (?)"]
record["closed_at"] = now()
save_record(record)
found = orphans([{"name": "ma-cle-perso", "hash": "p" * 8, "usage": 3.0},
{"name": f"{PREFIX}{rid}", "hash": "h" * 8, "usage": 0.01}],
alive=lambda r: False)
ok &= [k["name"] for k, _ in found] == [f"{PREFIX}{rid}"]
print(f"sandbox_keys {'OK' if ok else 'KO'} — prefixe, secret hors fiche, 0600, ecrasement,"
" cle personnelle jamais candidate, boite disparue = orpheline")
return 0 if ok else 1
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="la frontiere des credentials du hors-site")
parser.add_argument("--selftest", action="store_true", help="la gate a sec")
sub = parser.add_subparsers(dest="verb")
p = sub.add_parser("mint"); p.add_argument("run_id")
p.add_argument("--limit", type=float, default=DEFAULT_LIMIT, help="plafond en dollars")
p.add_argument("--ttl", type=int, default=DEFAULT_TTL_HOURS, help="expiration en heures")
sub.add_parser("spend").add_argument("run_id")
sub.add_parser("revoke").add_argument("run_id")
sub.add_parser("list")
sub.add_parser("reap").add_argument("--yes", action="store_true")
args = parser.parse_args()
if args.selftest:
raise SystemExit(selftest())
if args.verb == "mint":
mint(args.run_id, args.limit, args.ttl)
elif args.verb == "spend":
rec = load_record(args.run_id)
usd = spend(args.run_id, rec.get("key_hash"))
print(f"depense: {usd:.4f} $" if usd is not None else "depense: indisponible (cle absente)")
elif args.verb == "revoke":
revoke(args.run_id)
elif args.verb == "list":
show()
elif args.verb == "reap":
reap(args.yes)
else:
parser.print_help()
Pièce — just/sandbox/keys.just
Le module des clés, importé par lifecycle.just, pas monté en module : les recettes
partagent la portée du hors-site (set working-directory, set positional-arguments) et
s’appellent just sandbox mint, comme just sandbox mount. Règle des imports : aucun set
ici, aucune variable de fichier. Toutes les recettes tiennent en une ligne, la logique vit
dans le script.
# just/sandbox/keys.just — la frontiere des credentials : cles jetables par run (ch. 23).
# IMPORTE par lifecycle.just : aucun `set` ici (les reglages du module s'appliquent),
# aucune variable de fichier. La cle de gestion est lue par le script, jamais par just.
# fabriquer une cle jetable, fiche seule sans boite : just sandbox mint essai --limit 1 --ttl 2
mint NAME *FLAGS:
uv run adws/sandbox_keys.py mint "$@"
# la depense d'un run, lue sur sa cle : just sandbox spend <run>
spend RUN:
uv run adws/sandbox_keys.py spend "$1"
# revoquer la cle d'un run — depense relevee d'abord, gate sur la liste : just sandbox revoke <run>
revoke RUN:
uv run adws/sandbox_keys.py revoke "$1"
# toutes les cles sbx- du compte : nom, plafond, depense, expiration
keys:
uv run adws/sandbox_keys.py list
# le filet : revoquer les cles orphelines — a sec par defaut, --yes pour agir
reap *FLAGS:
uv run adws/sandbox_keys.py reap "$@"
Pièce — just/sandbox/lifecycle.just
Cette version remplace celle du chapitre 22. Une seule ligne s’ajoute, l’import de
keys.just annoncé hier, et rien d’autre ne bouge : vos six phases et egress tournent
telles quelles.
# just/sandbox/lifecycle.just — le hors-site : le cycle de vie d'une boite (ch. 22).
# Un module n'herite de rien : reglages redeclares, working-directory remonte de deux crans.
set working-directory := '../..'
set positional-arguments
set dotenv-load
set windows-shell := ["C:/Program Files/Git/bin/bash.exe", "-cu"]
# la frontiere des credentials : mint, spend, revoke, keys, reap (ch. 23)
import 'keys.just'
# liste les commandes du hors-site
default:
@just --list sandbox
# le preflight du hors-site (ch. 21) : zero token, quelques secondes
preflight:
uv run adws/sandbox_preflight.py
# l'image de base des boites Podman (Containerfile, ch. 21) : une fois, ~2 min, reseau ouvert
image:
podman build -t plume-node .
# la chaine create → fill → setup → observe — jamais teardown : just sandbox mount plume [--limit 5] [--memory 4g]
mount NAME *FLAGS:
uv run adws/sandbox_lifecycle.py mount "$@"
# phase 1 — la fiche, la boite (reseau interne, porte, conteneur), puis la cle jetable (hote seulement)
create NAME *FLAGS:
uv run adws/sandbox_lifecycle.py create "$@"
# phase 2 — le repo dans la boite, un commit de reference, la cle jetable par stdin
fill RUN:
uv run adws/sandbox_lifecycle.py fill "$1"
# phase 3 — provision, sentinelle, gate (a sec puis un scout) : just sandbox setup <run> [--config adws/adw_config/eco.config.yaml]
setup RUN *ARGS:
uv run adws/sandbox_lifecycle.py setup "$@"
# phase 4 — le travail, detache : just sandbox execute <run> "<demande>" [--config …] [--adw adw_plan]
execute RUN PROMPT *ARGS:
uv run adws/sandbox_lifecycle.py execute "$@"
# phase 5 — lire de l'exterieur : run.log, la table runs, Plume servie par la porte
observe RUN:
uv run adws/sandbox_lifecycle.py observe "$1"
# phase 6 — depense, preuve, cle revoquee, boite detruite : une decision, jamais enchainee
teardown RUN:
uv run adws/sandbox_lifecycle.py teardown "$1"
# les fiches connues : montees, en cours, fermees — avec moteur, cle et depense
list:
uv run adws/sandbox_lifecycle.py list
# le journal d'egress d'une boite : ce que la porte a laisse passer, ce qu'elle a refuse (Podman)
egress RUN:
podman logs --tail 40 plume-gate-"$1"
Pièce — adws/sandbox_lifecycle.py
Cette version remplace celle du chapitre 22. Les six phases sont les mêmes, et le port
« boîte » aussi. Ce qui change tient en trois endroits, annoncés hier. create fabrique la
clé jetable en dernier (fiche → boîte → accès → clé) et refuse de démarrer sans clé de gestion.
fill pousse la clé jetable, la ligne qui a changé de source, et plus jamais votre clé
personnelle. teardown relève la dépense, rapatrie la preuve, révoque avec gate, puis
seulement détruit. La fiche de run et ses aides sont désormais importées de sandbox_keys.py,
et mount accepte --limit et --ttl en plus des options du moteur.
#!/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.
Depuis le ch. 23, la frontiere des credentials est executable : `create`
fabrique une cle JETABLE (plafonnee, datee) apres la boite, `fill` la pousse
dans la boite — jamais votre cle personnelle —, `teardown` releve la depense,
revoque la cle, puis seulement detruit la boite. La cle de gestion
(OPENROUTER_PROVISIONING_KEY) est lue par sandbox_keys.py sur l'hote et ne
traverse jamais.
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> [--limit 5] [--ttl 24] [options du moteur…]
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 re
import shlex
import sys
import tarfile
import tempfile
import time
from pathlib import Path
# La fiche de run et la frontiere des credentials vivent dans sandbox_keys.py,
# le port « boite » dans sandbox_box.py (meme dossier : uv place adws/ en
# tete du chemin d'import).
import sandbox_box as boxes
import sandbox_keys as keys
from sandbox_keys import (die, load_record, new_record, new_run_id, now,
record_path, save_record)
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
touch "$HOME/.plume-factory-ready"
echo "provision : bun $(bun --version), $(just --version)"
"""
# 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 de cle jetable"; 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 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
def split_key_flags(flags: list[str]) -> tuple[float, int, list[str]]:
"""--limit et --ttl sont a nous ; tout le reste part tel quel au moteur
(`podman run` : --memory 4g, --cpus 2… ; `ssh exe.dev new` : --cpu 2…)."""
limit, ttl, rest = keys.DEFAULT_LIMIT, keys.DEFAULT_TTL_HOURS, []
it = iter(flags)
for flag in it:
if flag == "--limit":
limit = float(next(it, "0"))
elif flag.startswith("--limit="):
limit = float(flag.partition("=")[2])
elif flag == "--ttl":
ttl = int(next(it, "0"))
elif flag.startswith("--ttl="):
ttl = int(flag.partition("=")[2])
else:
rest.append(flag)
return limit, ttl, rest
# ── les six phases ──────────────────────────────────────────────────────────
def create(name: str, flags: list[str]) -> str:
"""Hote seulement : la fiche, puis la boite, puis la porte, puis la cle — dans cet ordre.
Un mint rate orphelinerait une boite : visible dans `list`, et gratuite
(Podman) ou gratuite a l'heure (exe.dev). Une boite ratee apres un mint
orphelinerait une cle : invisible, et capable de depenser. La cle vient EN DERNIER."""
limit, ttl, box_flags = split_key_flags(flags)
keys.provisioning_key() # verifiee AVANT toute creation ; jamais affichee
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, box_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")
# 4. la cle jetable, EN DERNIER : plafonnee, datee, ecrite en 0600 sur l'hote.
# La fiche recoit le hash et le plafond — jamais la cle.
keys.mint(run_id, limit, ttl)
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 : la cle JETABLE du run — la ligne qui a change de source depuis
# le ch. 22. Lue sur l'hote, ecrite dans la boite par stdin — jamais dans
# argv, jamais affichee. Votre cle personnelle et la cle de gestion ne
# traversent jamais : la boite ne connait que ce qu'elle peut depenser.
key = keys.read_runtime_key(run_id)
if not key:
die(f"aucune cle jetable pour {run_id} — `create` la fabrique ;"
f" ou : uv run adws/sandbox_keys.py mint {run_id}")
box.exec(record, "umask 077 && cat > app/.env && chmod 600 app/.env",
script=f"OPENROUTER_API_KEY={key}\n")
print(f"secret : app/.env ecrit (0600) — cle jetable {record.get('key_hash', '')[:12]}…,"
f" plafond {record.get('key_limit')} $ ; la cle de gestion n'a pas quitte l'hote")
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 JETABLE, 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:
"""La seule phase qui detruit — jamais enchainee. L'ordre porte le design :
depense → preuve → cle revoquee → boite detruite → fiche fermee.
Un plantage entre la revocation et la destruction laisse une cle morte et
une boite vivante (visible, gratuite). L'inverse laisserait une cle
vivante que personne ne retrouve (invisible, qui depense)."""
record = load_record(run_id)
box = boxes.for_record(record)
alive = bool(record.get("box") and box.exists(run_id))
# 1. la depense, AVANT que la cle meure : le chiffre est irrecuperable apres.
# C'est ce qui rendra un best-of-N comparable au ch. 25 : le cout a cote du resultat.
usd = keys.spend(run_id, record.get("key_hash"))
if usd is not None:
record["spend_usd"] = usd
save_record(record)
print(f"depense: {usd:.4f} $ sur la cle du run (plafond {record.get('key_limit')} $)")
# 2. la preuve, tant que la boite est debout.
if alive:
artifacts = keys.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)")
# 3. la cle : revoquee avec gate sur la liste. Un echec ici ARRETE tout —
# la boite reste debout, la fiche ouverte : une cle vivante ne se perd pas de vue.
if record.get("key_hash"):
verdict = keys.revoke_hash(record["key_hash"], f"{keys.PREFIX}{run_id}")
keys.shred(run_id)
print(f"cle : {record['key_hash'][:12]}… {verdict} — absente de la liste")
else:
print("cle : aucune fabriquee pour ce run — rien a revoquer")
# 4. la boite, en dernier : conteneur, porte et reseau (Podman) ou VM (exe.dev).
if alive:
box.destroy(record)
print(f"boite : {record['box']} detruite")
else:
print(f"boite : {record.get('box') or '<aucune>'} deja absente")
# 5. la fiche.
record["closed_at"] = now()
save_record(record)
print(f"fiche : {record_path(run_id)} fermee — `git apply {keys.RUNS_DIR}/{run_id}-artifacts/run.patch`"
" rejoue le travail chez vous, a cote de votre branche")
def list_runs() -> None:
keys.RUNS_DIR.mkdir(parents=True, exist_ok=True)
for path in sorted(keys.RUNS_DIR.glob("*.json")):
if path.name.startswith("bestof-"):
continue
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")
key = (r.get("key_hash") or "")[:12] + ("…" if r.get("key_hash") else "-")
usd = f"{r['spend_usd']:.4f} $" if r.get("spend_usd") is not None else "-"
print(f"{r['run_id']:<40} {r.get('backend') or '-':<7} {state:<12} {key:<14} {usd:<10} {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 (forme du ch. 23),
le filtre de chargement, le partage des options entre la cle et le moteur."""
with tempfile.TemporaryDirectory() as tmp:
keys.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))
record = load_record(rid)
ok &= record["run_id"] == rid and "key_hash" in record and "spend_usd" in record and "backend" in record
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"]
limit, ttl, rest = split_key_flags(["--limit", "2", "--memory", "4g", "--ttl=6"])
ok &= (limit, ttl, rest) == (2.0, 6, ["--memory", "4g"])
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 ; options cle/moteur separees ; deux moteurs")
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="--limit, --ttl, puis les options du moteur")
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()
La gate du TP
Quatre commandes, une par ligne, depuis la racine de plume-factory : les deux premières à
sec, les deux suivantes avec une clé de gestion (aucune boîte nécessaire) :
uv run adws/sandbox_keys.py --selftest
uv run adws/sandbox_lifecycle.py --selftest
just sandbox mint essai --limit 1 --ttl 2
just sandbox revoke essai-<la-date>-<6 hex>
Attendu : sandbox_keys OK — prefixe, secret hors fiche, 0600, ecrasement, cle personnelle jamais candidate, boite disparue = orpheline puis sandbox_lifecycle OK — … options cle/moteur separees ; deux moteurs (zéro réseau, zéro token). La troisième imprime cle : <hash>… plafond 1.0 $, expire <dans 2 h> et le chemin du
fichier 0600, et just sandbox keys la montre alors avec 0,0000 $ de dépense. La quatrième
relève la dépense, révoque, et termine sur gate verte : absente de la liste puis fiche … fermee : zéro token, quelques secondes, trois appels HTTP. Pour le cycle complet avec une
boîte : just sandbox mount plume --limit 5 puis just sandbox teardown <run>, comme hier, à
ceci près que teardown affiche désormais la dépense et la révocation avant de détruire.
Variante éco : rien à changer, la frontière ne coûte aucun token.