Introduction
Une application avec état peut sembler défaillante alors que ses données sont intactes. Le navigateur peut demander le mauvais Agent nommé : au lieu de se reconnecter à SupportRoutingAgent:planning, il peut ouvrir accidentellement SupportRoutingAgent:triage. Ces noms sélectionnent des instances différentes de Durable Objects adossées à SQLite. Modifier ou effacer l’état serait donc une mauvaise première réaction.
Dans cet exercice, le client de notes de support fourni contient précisément ce défaut de routage. Un jeton signé indique que l’utilisateur peut accéder à planning, tandis que le client sélectionne triage. Le serveur compare la session signée à la route réellement utilisée et rejette la différence avant de transmettre l’état. Vous lirez les indices provenant de trois couches :
- le navigateur affiche le nom attendu et le nom sélectionné ;
- les journaux Worker limités montrent quelle route a été autorisée ou rejetée ;
- des sondes indépendantes montrent que
planningconserve toujours son historique et qu’un autre Agent nommé reste vide.
Vous corrigerez ensuite le résolveur de routes, vous vous reconnecterez à l’Agent attendu, vous ajouterez une mise à jour normale, puis vous actualiserez la page. L’historique initial doit rester intact pendant toute l’opération. Il s’agit d’une habitude de diagnostic importante : identifiez la route avant de toucher aux données persistantes.
L’application utilise des notes fictives et aucun modèle de langage. Un jeton de session est une déclaration de courte durée, signée avec HMAC, qui indique le nom de la session autorisée. Il convient pour illustrer l’autorisation des routes, mais une application de production doit émettre ces jetons uniquement après avoir authentifié un utilisateur réel et doit appliquer des politiques plus robustes de rotation des clés et d’audit.
Avant d’accéder directement à ce cours, terminez Connect LabEx to Your Cloudflare Account. Chaque nouvelle VM LabEx a besoin de sa propre autorisation Wrangler. Les exercices précédents du cours présentent l’identité des Agents et l’état synchronisé, mais cet exercice réexplique les notions utiles au moment où vous les employez.
Autoriser la VM et nommer un Worker temporaire
Dans cette étape, vous autoriserez la nouvelle VM, vérifierez le compte d’apprentissage prévu et déclarerez un Worker temporaire portant un nom unique.
Ouvrez un terminal et accédez au projet préparé :
cd /home/labex/project/agent-routing-diagnostics
npx wrangler login --device --browser=false
Wrangler affiche une URL et ouvre une page d’autorisation. Vérifiez qu’elle indique le compte Cloudflare d’apprentissage dédié que vous souhaitez utiliser, puis approuvez les autorisations Workers demandées. Ne collez jamais de mot de passe, de code d’autorisation ou de jeton dans le contenu du cours.
Examinez le résultat d’identité structuré :
npx wrangler whoami --json
Vérifiez que loggedIn vaut true et identifiez le compte d’apprentissage dédié grâce à son nom d’affichage. Sélectionnez son ID sans l’afficher, puis générez un nom unique pour le Worker temporaire et une clé de signature locale :
WHOAMI="$(npx wrangler whoami --json)"
printf '%s\n' "$WHOAMI" | jq '{loggedIn, authType, accounts: [.accounts[] | {name}]}'
export LAB_ACCOUNT_ID="$(printf '%s\n' "$WHOAMI" | jq -r '.accounts[] | select(.name == "LabEx Learning") | .id')"
test -n "$LAB_ACCOUNT_ID"
export LAB_WORKER="labex-c11-s08-$(openssl rand -hex 6)"
export SESSION_SIGNING_KEY="$(openssl rand -hex 32)"
printf 'SESSION_SIGNING_KEY=%s\n' "$SESSION_SIGNING_KEY" > .dev.vars
Créez la configuration du Worker :
cat > wrangler.jsonc <<JSON
{
"\$schema": "node_modules/wrangler/config-schema.json",
"name": "$LAB_WORKER",
"account_id": "$LAB_ACCOUNT_ID",
"main": "src/server.ts",
"compatibility_date": "2026-09-18",
"compatibility_flags": ["nodejs_compat"],
"workers_dev": true,
"preview_urls": false,
"observability": { "enabled": true },
"durable_objects": {
"bindings": [
{ "name": "SupportRoutingAgent", "class_name": "SupportRoutingAgent" }
]
},
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["SupportRoutingAgent"] }
]
}
JSON
SupportRoutingAgent est à la fois le nom de la liaison du Worker et celui de la classe exportée. Le SDK associe chaque nom d’instance en minuscules, comme planning ou triage, à un Durable Object différent adossé à SQLite. La migration crée l’espace de noms de la classe ; elle ne crée pas à l’avance toutes les instances nommées.
Si votre compte d’apprentissage dédié utilise un autre nom d’affichage, remplacez uniquement LabEx Learning après avoir confirmé qu’il s’agit du bon compte. Conservez pour le moment la clé de signature localement ; vous ne la téléverserez qu’une fois le Worker corrigé créé :
unset SESSION_SIGNING_KEY
Exécutez la vérification indépendante de l’identité et de la configuration :
python3 .labex/verify.py authorization
Résultat attendu :
PASS: authorization
Implémenter un Agent avec état lié à la session
Dans cette étape, vous implémenterez l’état persistant des notes et imposerez la limite de session signée sur chaque route de l’Agent.
Créez le vérificateur de jeton :
cat > src/session-auth.ts <<'TS'
type SessionClaims = { session: string; exp: number };
function decodeBase64Url(value: string): Uint8Array<ArrayBuffer> {
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
const binary = atob(normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="));
const bytes = new Uint8Array(new ArrayBuffer(binary.length));
for (let index = 0; index < binary.length; index++) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
function encodeText(value: string): Uint8Array<ArrayBuffer> {
const encoded = new TextEncoder().encode(value);
const bytes = new Uint8Array(new ArrayBuffer(encoded.byteLength));
bytes.set(encoded);
return bytes;
}
export async function verifySessionRequest(
request: Request,
expectedSession: string,
secret: string
): Promise<Response | undefined> {
const rawToken = new URL(request.url).searchParams.get("token");
if (!rawToken) return new Response("Missing session token", { status: 401 });
const [payload, signature, extra] = rawToken.split(".");
if (!payload || !signature || extra) return new Response("Invalid session token", { status: 401 });
try {
const key = await crypto.subtle.importKey(
"raw",
encodeText(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["verify"]
);
const valid = await crypto.subtle.verify(
"HMAC",
key,
decodeBase64Url(signature),
encodeText(payload)
);
if (!valid) return new Response("Invalid session token", { status: 401 });
const claims = JSON.parse(new TextDecoder().decode(decodeBase64Url(payload))) as SessionClaims;
if (claims.session !== expectedSession || claims.exp <= Math.floor(Date.now() / 1000)) {
return new Response("Session token does not match this Agent", { status: 401 });
}
return undefined;
} catch {
return new Response("Invalid session token", { status: 401 });
}
}
TS
La signature prouve que la revendication de session n’a pas été modifiée. La seconde vérification est tout aussi importante : claims.session doit être égal au nom sélectionné par la route réelle de l’Agent. Un jeton valide pour planning est donc invalide pour triage.
Créez le serveur avec état :
cat > src/server.ts <<'TS'
import { Agent, callable, routeAgentRequest } from "agents";
import { verifySessionRequest } from "./session-auth";
type SessionState = {
notes: string[];
revision: number;
lastEvent: "initialized" | "note-added";
};
type Env = {
SupportRoutingAgent: DurableObjectNamespace<SupportRoutingAgent>;
SESSION_SIGNING_KEY: string;
};
export class SupportRoutingAgent extends Agent<Env, SessionState> {
initialState: SessionState = { notes: [], revision: 0, lastEvent: "initialized" };
@callable()
addNote(noteInput: string): SessionState {
const note = noteInput.trim();
if (note.length < 3 || note.length > 80) {
throw new Error("A note must contain 3-80 characters.");
}
const next: SessionState = {
notes: [...this.state.notes, note].slice(-6),
revision: this.state.revision + 1,
lastEvent: "note-added"
};
this.setState(next);
console.log(JSON.stringify({
event: "agent_state_changed",
instance: this.name,
revision: next.revision,
noteCount: next.notes.length
}));
return next;
}
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const authorize = async (candidate: Request, route: { name: string }) => {
const rejection = await verifySessionRequest(candidate, route.name, env.SESSION_SIGNING_KEY);
console.log(JSON.stringify({
event: "agent_route_checked",
requestedSession: route.name,
outcome: rejection ? "rejected" : "allowed"
}));
return rejection;
};
return (await routeAgentRequest(request, env, {
onBeforeConnect: authorize,
onBeforeRequest: authorize
})) ?? new Response("Not found", { status: 404 });
}
} satisfies ExportedHandler<Env>;
TS
cat > tsconfig.json <<'JSON'
{
"extends": "agents/tsconfig",
"compilerOptions": {
"types": ["@cloudflare/workers-types", "node"]
},
"include": ["src/**/*.ts", "vite.config.ts", "worker-configuration.d.ts"]
}
JSON
cat > vite.config.ts <<'TS'
import { cloudflare } from "@cloudflare/vite-plugin";
import agents from "agents/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [agents(), cloudflare()]
});
TS
npx wrangler types
python3 .labex/verify.py server
Les journaux contiennent volontairement uniquement le nom de la route, la décision, l’instance, la révision et le nombre d’éléments. Ils ne contiennent jamais le jeton ni le texte des notes. La trace de diagnostic reste ainsi utile sans transformer l’observabilité en seconde fuite de données.
Reproduire le symptôme du mauvais nom en toute sécurité
Dans cette étape, vous exécuterez le client défectueux fourni et observerez un échec d’autorisation sécurisé avant toute transmission d’état.
Créez le client de navigateur fourni :
cat > src/client.ts <<'TS'
import { AgentClient } from "agents/client";
import { resolveAgentName } from "./route";
type SessionState = {
notes: string[];
revision: number;
lastEvent: "initialized" | "note-added";
};
const parameters = new URLSearchParams(location.search);
const session = parameters.get("session") ?? "planning";
const token = parameters.get("token") ?? "";
const selectedName = resolveAgentName(session);
const intended = document.querySelector<HTMLElement>("#intended")!;
const selected = document.querySelector<HTMLElement>("#selected")!;
const status = document.querySelector<HTMLElement>("#status")!;
const revision = document.querySelector<HTMLElement>("#revision")!;
const notes = document.querySelector<HTMLUListElement>("#notes")!;
const form = document.querySelector<HTMLFormElement>("#note-form")!;
const input = document.querySelector<HTMLInputElement>("#note")!;
const button = form.querySelector<HTMLButtonElement>("button")!;
const error = document.querySelector<HTMLElement>("#error")!;
intended.textContent = session;
selected.textContent = selectedName;
button.disabled = true;
let receivedState = false;
function escapeHtml(value: string): string {
return value.replace(/[&<>]/g, (character) =>
character === "&" ? "&" : character === "<" ? "<" : ">"
);
}
function render(state: SessionState) {
revision.textContent = `Revision ${state.revision}`;
notes.innerHTML = state.notes.length
? state.notes.map((note) => `<li>${escapeHtml(note)}</li>`).join("")
: '<li class="empty">This named Agent has no notes.</li>';
}
const client = new AgentClient<SessionState>({
agent: "SupportRoutingAgent",
name: selectedName,
host: location.host,
query: { token },
onStateUpdate(state) {
receivedState = true;
render(state);
button.disabled = false;
status.textContent = `Connected to SupportRoutingAgent:${selectedName}`;
status.className = "status connected";
}
});
client.ready.catch(() => undefined);
setTimeout(() => {
if (!receivedState) {
status.textContent = `Blocked before state delivery: token for ${session} cannot open ${selectedName}`;
status.className = "status blocked";
}
}, 1800);
form.addEventListener("submit", async (event) => {
event.preventDefault();
error.textContent = "";
try {
await client.call("addNote", [input.value]);
input.value = "";
} catch (caught) {
error.textContent = caught instanceof Error ? caught.message : String(caught);
}
});
TS
Démarrez le runtime local en arrière-plan :
CI=true npm run dev > .labex/vite.log 2>&1 < /dev/null &
echo $! > .labex/vite.pid
sleep 8
curl -fsS http://127.0.0.1:5173/ > /dev/null
Générez un jeton pour la session planning attendue et affichez une URL de navigateur :
TOKEN="$(node scripts/create-session-token.mjs planning)"
printf 'http://localhost:5173/?session=planning&token=%s\n' "$TOKEN"
unset TOKEN
Ouvrez l’URL affichée dans l’aperçu du navigateur LabEx. Les deux cartes de routage doivent afficher :
Intended session planning
Selected Agent name triage
Après un court délai, l’état devient Blocked before state delivery. L’historique reste inaccessible. Cet échec sécurisé est attendu : le client a demandé le mauvais Agent et le serveur l’a rejeté avant de renvoyer l’état.
Exécutez la vérification déterministe du symptôme :
python3 .labex/verify.py client
python3 .labex/verify.py symptom
Résultats attendus :
PASS: client
PASS: symptom
Suivre la route avant de toucher à l’état
Dans cette étape, vous combinerez les indices du navigateur et du serveur pour localiser le défaut de routage, puis vous corrigerez uniquement le résolveur de noms.
Examinez le résolveur qui a choisi le nom sélectionné :
sed -n '1,120p' src/route.ts
L’entrée est normalisée et validée, mais la dernière ligne l’ignore :
return "triage";
Examinez maintenant uniquement les événements de routage locaux et limités :
grep 'agent_route_checked' .labex/vite.log | tail -5
Vous devriez voir un événement similaire à celui-ci :
{"event":"agent_route_checked","requestedSession":"triage","outcome":"rejected"}
Le navigateur fournit la première moitié du diagnostic : planning était attendu, mais triage a été sélectionné. Le serveur fournit la seconde : triage a été rejeté. Aucune de ces sources n’est aussi claire que leur combinaison.
Ne supprimez pas les Durable Objects, n’effacez pas le stockage du navigateur et ne générez pas de jeton pour triage. Ces actions masqueraient le défaut ou affaibliraient la règle d’autorisation. Corrigez la sélection du nom :
python3 - <<'PY'
from pathlib import Path
path = Path('src/route.ts')
text = path.read_text()
old = ' // Intentional lab defect: every browser is sent to the triage Agent.\n return "triage";'
new = ' // Route to the validated session requested by this page.\n return normalized;'
if old not in text:
raise SystemExit('The expected supplied defect was not found.')
path.write_text(text.replace(old, new))
PY
Vite recharge automatiquement le client. Rouvrez la même URL planning si nécessaire. Les deux cartes de routage doivent maintenant afficher planning, l’état doit être vert et l’Agent doit transmettre son état actuel.
Prouver la récupération, la reconnexion et l’isolation
Dans cette étape, vous prouverez que l’historique survit à une reconnexion, que les mises à jour normales continuent de fonctionner et qu’un autre Agent nommé reste isolé.
La première connexion réussie à une nouvelle instance planning affiche la révision 0. Ajoutez cette note fictive dans la page :
Preserve planning history during route repair
La révision passe à 1. Actualisez la page du navigateur. La même note et la même révision doivent réapparaître, car le client corrigé sélectionne le même Agent nommé et son état est stocké dans SQLite, et non dans la page.

L’exécution de test acceptée ci-dessus utilise un texte de note fictif et le nom temporaire planning. Votre note peut être différente ; l’important est que les deux cartes de routage concordent et que la révision 1 soit visible.

Après l’actualisation, la note et la révision inchangées montrent que l’état provient de l’Agent nommé plutôt que de la mémoire du navigateur.
Ajoutez une note supplémentaire après l’actualisation :
Confirm normal updates after reconnect
La révision passe à 2. Cela permet de distinguer deux questions faciles à confondre :

- Récupération : l’ancien historique est-il revenu après la reconnexion ?
- Disponibilité : la session corrigée peut-elle encore accepter une nouvelle mise à jour normale ?
Générez une URL autorisée séparément pour un autre Agent nommé :
PRIVATE_TOKEN="$(node scripts/create-session-token.mjs private)"
printf 'http://localhost:5173/?session=private&token=%s\n' "$PRIVATE_TOKEN"
unset PRIVATE_TOKEN
Ouvrez-la dans un deuxième onglet d’aperçu. Il doit afficher private dans les deux cartes de routage, ainsi que la révision 0 sans aucune note. Un autre Agent nommé ne doit pas recevoir l’historique de planning, même si les deux instances utilisent la même classe.

La session private vide constitue un indice visuel d’orientation. La sonde indépendante ci-dessous reste la référence, car elle vérifie également le comportement de reconnexion et le rejet HTTP 401 d’une session différente.
Exécutez la sonde indépendante. Elle utilise de nouveaux noms aléatoires, écrit une note, ferme puis rouvre la connexion, écrit une seconde note, vérifie qu’une session distincte reste vide et confirme qu’un jeton d’une autre session reçoit HTTP 401 :
npm run check
python3 .labex/verify.py repaired
Résultat attendu :
PASS: repaired
Déployer la route corrigée
Dans cette étape, vous déploierez l’application corrigée et répéterez les vérifications de récupération et d’isolation sur Cloudflare.
Effectuez une nouvelle compilation, déployez exactement l’application corrigée, puis téléversez la clé de signature locale comme secret Worker chiffré :
npm run check
npm run deploy
npx wrangler secret bulk .dev.vars
Wrangler affiche une URL se terminant par .workers.dev. Générez un nouveau jeton planning et ajoutez-le à cette URL :
TOKEN="$(node scripts/create-session-token.mjs planning)"
printf 'https://%s.YOUR_WORKERS_SUBDOMAIN.workers.dev/?session=planning&token=%s\n' "$LAB_WORKER" "$TOKEN"
unset TOKEN
Remplacez YOUR_WORKERS_SUBDOMAIN par le sous-domaine indiqué dans le résultat du déploiement de Wrangler, puis ouvrez l’URL. Vérifiez que les noms attendu et sélectionné affichent tous deux planning, puis ajoutez une note fictive et actualisez la page. L’historique distant doit réapparaître exactement comme l’historique local.
Les instances locale et distante ne partagent pas leurs données : l’état local appartient au runtime de développement, tandis que le Worker déployé possède un espace de noms Durable Object Cloudflare. C’est le comportement, et non le nombre littéral de notes, qui doit être identique.
Exécutez la vérification distante indépendante :
python3 .labex/verify.py deployed
Résultat attendu :
PASS: deployed
Lire les indices Cloudflare et supprimer les ressources créées
Dans cette étape, vous examinerez les indices de routage limités, puis vous supprimerez uniquement le Worker et l’espace de noms Agent créés pendant cette exécution.
Ouvrez Workers & Pages, sélectionnez le Worker dont le nom commence par labex-c11-s08-, puis ouvrez Settings → Bindings. Vérifiez que SupportRoutingAgent pointe vers la classe SupportRoutingAgent. La liaison identifie l’espace de noms de la classe ; chaque nom de route sélectionne toujours une instance distincte à l’intérieur de cet espace.

Le nom du Worker temporaire utilisé dans cette exécution acceptée n’est qu’un exemple. Utilisez le nom unique exact généré dans votre propre VM.
Ouvrez la section Durable Objects du compte et recherchez l’espace de noms SQLite appartenant à ce Worker et à cette classe précis. N’utilisez pas l’ID d’espace de noms provenant d’un exemple ou d’une autre exécution.

Revenez au Worker et ouvrez Observability → Logs. Filtrez sur agent_route_checked. Une exécution utile contient des décisions rejetées et autorisées pour différentes requêtes de diagnostic. Les événements doivent révéler les noms de route et les résultats, mais jamais les jetons ni le texte des notes. Des journaux récents vides ne permettent pas de conclure, car leur ingestion peut être différée ; les sondes indépendantes en direct restent la référence.

L’événement développé de l’exécution acceptée affiche la session demandée et un résultat autorisé, tandis que Cloudflare masque le jeton. Les journaux aident à expliquer une décision, mais c’est le vérificateur en direct qui détermine si le routage et l’isolation fonctionnent.
Vérifiez l’inventaire cloud créé avant de supprimer quoi que ce soit :
python3 .labex/verify.py observed
Résultat attendu :
PASS: observed
Supprimez l’espace de noms de la classe Agent avec une migration append-only. Conservez la migration v1 originale et ajoutez v2 :
python3 - <<'PY'
import json
from pathlib import Path
source = json.loads(Path('wrangler.jsonc').read_text())
source.pop('durable_objects', None)
source['migrations'].append({'tag': 'v2', 'deleted_classes': ['SupportRoutingAgent']})
Path('wrangler.cleanup.jsonc').write_text(json.dumps(source, indent=2) + '\n')
PY
npx wrangler deploy --config wrangler.cleanup.jsonc
npx wrangler delete --config wrangler.cleanup.jsonc --force
Supprimer uniquement le script Worker ne retire pas explicitement la classe Durable Object. La migration supprime d’abord l’espace de noms de la classe créé pour cet exercice ; la seconde commande supprime ensuite précisément le Worker de cet exercice.
Prouvez que les deux ressources ont disparu alors que l’autorisation reste valide :
python3 .labex/verify.py deleted
Résultat attendu :
PASS: deleted
Actualisez les listes Worker et Durable Objects dans le Dashboard. Les noms temporaires exacts ne doivent plus apparaître. Ne supprimez jamais une ressource portant un nom similaire que vous n’avez pas créée dans cet exercice.


Ces captures montrent l’exécution temporaire acceptée après le nettoyage. Votre compte peut contenir des ressources sans rapport ; l’absence doit être vérifiée avec les noms exacts de votre Worker et de votre espace de noms, et le vérificateur en lecture seule ci-dessus fait foi.
Se déconnecter de la VM temporaire
Dans cette étape, vous supprimerez l’autorisation Wrangler enregistrée sur la nouvelle VM, après avoir prouvé que les ressources ont été nettoyées.
Supprimez l’autorisation Cloudflare enregistrée sur la VM :
npx wrangler logout
npx wrangler whoami --json
Le résultat structuré doit contenir :
{"loggedIn":false}
Exécutez la dernière vérification indépendante :
python3 .labex/verify.py logout
Résultat attendu :
PASS: logout
La déconnexion de la VM ne supprime pas les ressources cloud ; c’est pourquoi leur suppression a été vérifiée auparavant. Elle ne déconnecte pas non plus votre navigateur habituel du Cloudflare Dashboard.
Résumé
Vous avez diagnostiqué une défaillance de routage avec état sans supprimer de données saines. Le navigateur a révélé qu’il devait ouvrir planning, mais qu’il sélectionnait triage ; le serveur a rejeté en toute sécurité la différence entre la session signée et la route avant de transmettre l’état ; les journaux limités ont confirmé la décision de routage réelle. Vous avez corrigé le résolveur afin qu’il renvoie le nom attendu validé, puis prouvé la récupération de l’historique persistant, le fonctionnement des mises à jour normales après reconnexion, l’isolation entre noms distincts et le rejet des sessions croisées, localement et sur Cloudflare.
La règle de débogage centrale est réutilisable : lorsqu’un Agent semble vide ou indisponible, comparez la session attendue, le nom de l’Agent sélectionné et la décision d’autorisation du serveur avant de modifier l’état. L’identité d’un Agent nommé fait partie de la frontière des données ; ce n’est pas seulement un libellé d’affichage.



