Diagnostiquer une session Agent mal routée

CloudflareBeginner
Pratiquer maintenant

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 :

  1. le navigateur affiche le nom attendu et le nom sélectionné ;
  2. les journaux Worker limités montrent quelle route a été autorisée ou rejetée ;
  3. des sondes indépendantes montrent que planning conserve 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 === "&" ? "&amp;" : character === "<" ? "&lt;" : "&gt;"
  );
}

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.

Session planning corrigée à la révision un

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.

Historique de planning restauré après actualisation

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 :

Une mise à jour normale après reconnexion fait passer la révision à deux

  • 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.

Un Agent private autorisé séparément reste vide

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.

Worker déployé et liaison SupportRoutingAgent

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.

Espace de noms Durable Object SQLite créé pour la classe Agent

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.

Événement agent_route_checked limité dans les journaux Cloudflare

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.

Le Worker temporaire exact n’apparaît plus

Aucun espace de noms Durable Object ne reste de l’exécution acceptée

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.