Introducción
Una aplicación con estado puede parecer dañada aunque sus datos estén intactos. El navegador podría solicitar el Agent con nombre incorrecto: en lugar de volver a conectarse a SupportRoutingAgent:planning, podría abrir accidentalmente SupportRoutingAgent:triage. Esos nombres seleccionan instancias diferentes de Durable Objects respaldadas por SQLite, por lo que cambiar o borrar el estado no es la respuesta adecuada al principio.
En este laboratorio, el cliente de notas de soporte proporcionado contiene exactamente ese defecto de enrutamiento. Un token firmado indica que el usuario puede entrar en planning, mientras que el cliente selecciona triage. El servidor compara la sesión firmada con la ruta real y rechaza la discrepancia antes de entregar el estado. Leerá evidencias de tres capas:
- el navegador muestra los nombres previsto y seleccionado;
- los registros acotados del Worker indican qué ruta se permitió o se rechazó;
- las sondas independientes muestran que
planningaún conserva su historial y que otro Agent con nombre permanece vacío.
Después corregirá el resolvedor de rutas, se volverá a conectar al Agent previsto, añadirá una actualización normal y actualizará la página. El historial original debe conservarse durante todo el proceso. Este es un hábito de diagnóstico importante: identifique la ruta antes de tocar los datos persistentes.
La aplicación utiliza notas sintéticas y ningún modelo de lenguaje. Un token de sesión es una declaración de corta duración, firmada con HMAC, que indica la sesión permitida. Sirve para demostrar la autorización de rutas, pero una aplicación de producción solo debería emitir estos tokens después de autenticar a un usuario real y debería utilizar políticas más sólidas de rotación de claves y auditoría.
Antes de entrar directamente en este curso, complete Conecte LabEx a su cuenta de Cloudflare. Cada VM nueva de LabEx necesita su propia autorización de Wrangler. Los laboratorios anteriores del curso enseñan la identidad de Agent y el estado sincronizado, pero en este laboratorio volverá a ver las ideas relevantes en el momento de utilizarlas.
Autorice la VM y asigne un nombre a un Worker desechable
En este paso autorizará la VM nueva, confirmará la cuenta de aprendizaje prevista y declarará un Worker desechable con un nombre único.
Abra un terminal y entre en el proyecto preparado:
cd /home/labex/project/agent-routing-diagnostics
npx wrangler login --device --browser=false
Wrangler muestra una URL y abre una página de autorización. Confirme que indica la cuenta de aprendizaje de Cloudflare dedicada que desea utilizar y apruebe los permisos de Workers solicitados. Nunca pegue una contraseña, un código de autorización ni un token en el contenido del curso.
Inspeccione el resultado estructurado de identidad:
npx wrangler whoami --json
Confirme que loggedIn sea true e identifique la cuenta de aprendizaje dedicada por su nombre visible. Seleccione su ID sin imprimirlo y genere un nombre único para el Worker desechable y una clave de firma local:
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
Cree la configuración del 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 es tanto el binding del Worker como el nombre de la clase exportada. El SDK asignará cada nombre de instancia en minúsculas, como planning o triage, a un Durable Object diferente respaldado por SQLite. La migración crea el espacio de nombres de la clase; no crea previamente todas las instancias con nombre.
Si su cuenta de aprendizaje dedicada utiliza otro nombre visible, reemplace únicamente LabEx Learning después de confirmar que se trata de la cuenta correcta. Mantenga la clave de firma local por ahora; solo la cargará después de que exista el Worker corregido:
unset SESSION_SIGNING_KEY
Ejecute la comprobación independiente de identidad y configuración:
python3 .labex/verify.py authorization
Resultado esperado:
PASS: authorization
Implemente un Agent con estado vinculado a la sesión
En este paso implementará el estado persistente de las notas y aplicará el límite de la sesión firmada en cada ruta del Agent.
Cree el verificador de tokens:
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 firma demuestra que la declaración de sesión no ha sido modificada. La segunda comprobación es igual de importante: claims.session debe coincidir con el nombre seleccionado por la ruta real del Agent. Por lo tanto, un token válido para planning no es válido para triage.
Cree el servidor con estado:
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
Los registros contienen deliberadamente solo el nombre de la ruta, la decisión, la instancia, la revisión y la cantidad. Nunca contienen el token ni el texto de la nota. Así, el rastro de diagnóstico resulta útil sin convertir la observabilidad en una segunda fuga de datos.
Reproduzca de forma segura el síntoma del nombre incorrecto
En este paso ejecutará el cliente defectuoso proporcionado y observará un fallo de autorización seguro antes de que se entregue cualquier estado.
Cree el cliente de navegador proporcionado:
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
Inicie el entorno de ejecución local como un proceso separado:
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
Genere un token para la sesión prevista planning e imprima una URL del navegador:
TOKEN="$(node scripts/create-session-token.mjs planning)"
printf 'http://localhost:5173/?session=planning&token=%s\n' "$TOKEN"
unset TOKEN
Abra la URL impresa en la vista previa del navegador de LabEx. Las dos tarjetas de ruta deben mostrar:
Intended session planning
Selected Agent name triage
Después de esperar un momento, el estado cambia a Blocked before state delivery. El historial sigue sin estar disponible. Este es un fallo seguro correcto: el cliente solicitó el Agent incorrecto y el servidor lo rechazó antes de devolver el estado.
Ejecute la comprobación determinista del síntoma:
python3 .labex/verify.py client
python3 .labex/verify.py symptom
Resultados esperados:
PASS: client
PASS: symptom
Rastree la ruta antes de tocar el estado
En este paso combinará las evidencias del navegador y del servidor para localizar el defecto de enrutamiento y después corregirá únicamente el resolvedor de nombres.
Inspeccione el resolvedor que eligió el nombre seleccionado:
sed -n '1,120p' src/route.ts
La entrada se normaliza y valida, pero la última línea la ignora:
return "triage";
Ahora inspeccione únicamente los eventos de enrutamiento locales y acotados:
grep 'agent_route_checked' .labex/vite.log | tail -5
Debería ver un evento similar a este:
{"event":"agent_route_checked","requestedSession":"triage","outcome":"rejected"}
El navegador aporta la primera mitad del diagnóstico: planning era la sesión prevista, pero se seleccionó triage. El servidor aporta la segunda: triage fue rechazado. Ninguna fuente por sí sola es tan clara como ambas juntas.
No elimine Durable Objects, no borre el almacenamiento del navegador ni genere un token para triage. Esas acciones ocultarían el defecto o debilitarían la regla de autorización. Corrija la selección del nombre:
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 vuelve a cargar el cliente automáticamente. Si es necesario, vuelva a abrir la misma URL de planning. Ahora ambas tarjetas de ruta deben indicar planning, el estado debe aparecer en verde y el Agent debe entregar su estado actual.
Demuestre la recuperación, la reconexión y el aislamiento
En este paso demostrará que el historial sobrevive a una reconexión, que las actualizaciones normales continúan y que otro Agent con nombre permanece aislado.
La primera conexión correcta a una instancia nueva de planning muestra la revisión 0. Añada esta nota sintética en la página:
Preserve planning history during route repair
La revisión avanza a 1. Actualice la página del navegador. La misma nota y la misma revisión deben volver a aparecer porque el cliente corregido selecciona el mismo Agent con nombre y su estado reside en SQLite, no en la página.

La ejecución de prueba aceptada anterior utiliza texto de nota sintético y el nombre desechable planning. Su nota puede ser diferente; la evidencia importante es que ambas tarjetas de ruta coincidan y que se muestre la revisión 1.

Después de actualizar la página, la nota y la revisión sin cambios demuestran que el estado volvió desde el Agent con nombre y no desde la memoria del navegador.
Añada otra nota después de actualizar la página:
Confirm normal updates after reconnect
La revisión avanza a 2. Esto separa dos preguntas que se confunden fácilmente:

- Recuperación: ¿volvió el historial anterior después de reconectarse?
- Actividad: ¿la sesión corregida todavía puede aceptar una nueva actualización normal?
Genere una URL con autorización independiente para otro Agent con nombre:
PRIVATE_TOKEN="$(node scripts/create-session-token.mjs private)"
printf 'http://localhost:5173/?session=private&token=%s\n' "$PRIVATE_TOKEN"
unset PRIVATE_TOKEN
Ábrala en una segunda pestaña de vista previa. Debe mostrar private en ambas tarjetas de ruta y la revisión 0, sin notas. Un Agent con nombre diferente no debe recibir el historial de planning, aunque ambas instancias utilicen la misma clase.

La sesión private vacía sirve como evidencia visual de orientación. La sonda independiente siguiente sigue siendo la autoridad, porque también comprueba el comportamiento de reconexión y un rechazo HTTP 401 entre sesiones.
Ejecute la sonda independiente. Utiliza nombres aleatorios nuevos, escribe una nota, cierra y vuelve a conectarse, escribe otra nota, confirma que una sesión separada permanece vacía y confirma que un token de otra sesión recibe HTTP 401:
npm run check
python3 .labex/verify.py repaired
Resultado esperado:
PASS: repaired
Despliegue la ruta corregida
En este paso desplegará la aplicación corregida y repetirá las comprobaciones de recuperación y aislamiento contra Cloudflare.
Vuelva a compilar, despliegue exactamente la aplicación corregida y cargue la clave de firma local como un secreto cifrado del Worker:
npm run check
npm run deploy
npx wrangler secret bulk .dev.vars
Wrangler muestra una URL que termina en .workers.dev. Genere un token nuevo para planning y añádalo a esa 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
Reemplace YOUR_WORKERS_SUBDOMAIN por el subdominio que aparece en el resultado del despliegue de Wrangler y abra la URL. Confirme que los nombres previsto y seleccionado muestran planning, añada una nota sintética y actualice la página. El historial remoto debe volver exactamente como volvió el historial local.
Las instancias local y remota no comparten datos: el estado local pertenece al entorno de desarrollo, mientras que el Worker desplegado posee un espacio de nombres de Durable Objects de Cloudflare. El comportamiento, no la cantidad literal de notas, debe coincidir.
Ejecute la comprobación remota independiente:
python3 .labex/verify.py deployed
Resultado esperado:
PASS: deployed
Consulte las evidencias de Cloudflare y elimine el estado que creó
En este paso inspeccionará evidencias de enrutamiento acotadas y después eliminará únicamente el Worker y el espacio de nombres de Agent creados durante esta ejecución.
Abra Workers & Pages, seleccione el Worker cuyo nombre comienza por labex-c11-s08- e inspeccione Settings → Bindings. Confirme que SupportRoutingAgent apunta a la clase SupportRoutingAgent. El binding identifica el espacio de nombres de la clase; cada nombre de ruta sigue seleccionando una instancia distinta dentro de ese espacio.

El nombre del Worker desechable de esta ejecución aceptada es solo un ejemplo. Utilice el nombre único exacto que se generó en su propia VM.
Abra el área Durable Objects de la cuenta y localice el espacio de nombres SQLite perteneciente a este Worker y esta clase exactos. No utilice el ID de espacio de nombres de un ejemplo ni de otra ejecución.

Vuelva al Worker y abra Observability → Logs. Filtre por agent_route_checked. Una ejecución útil contiene decisiones rechazadas y permitidas para distintas solicitudes de diagnóstico. Los eventos deben mostrar nombres de rutas y resultados, nunca tokens ni texto de notas. Los registros recientes vacíos no son concluyentes porque la ingestión puede retrasarse; las sondas activas independientes siguen siendo la autoridad.

El evento ampliado de la ejecución aceptada muestra la sesión solicitada y un resultado permitido, mientras Cloudflare oculta el token. Los registros ayudan a explicar una decisión, pero el verificador activo sigue determinando si el enrutamiento y el aislamiento funcionan.
Verifique el inventario en la nube que creó antes de eliminar nada:
python3 .labex/verify.py observed
Resultado esperado:
PASS: observed
Elimine el espacio de nombres de la clase Agent con una migración de solo adición. Conserve la migración v1 original y añada 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
Eliminar únicamente el script del Worker no retira explícitamente la clase Durable Object. La migración elimina primero el espacio de nombres de la clase de este laboratorio; el segundo comando elimina después el Worker exacto de este laboratorio.
Demuestre que ambos recursos han desaparecido mientras la autorización sigue siendo válida:
python3 .labex/verify.py deleted
Resultado esperado:
PASS: deleted
Actualice las listas de Workers y Durable Objects en el Dashboard. Los nombres desechables exactos ya no deben aparecer. Nunca elimine un recurso con un nombre parecido que usted no haya creado en este laboratorio.


Estas capturas muestran la ejecución desechable aceptada después de la limpieza. Su cuenta puede contener recursos no relacionados; la ausencia debe comprobarse con los nombres exactos de su Worker y su espacio de nombres. El verificador de solo lectura anterior es la autoridad.
Cierre la sesión de la VM desechable
En este paso eliminará la autorización de Wrangler almacenada en la VM nueva después de comprobar la limpieza de los recursos.
Elimine la autorización de Cloudflare almacenada en la VM:
npx wrangler logout
npx wrangler whoami --json
El resultado estructurado debe incluir:
{"loggedIn":false}
Ejecute la comprobación independiente final:
python3 .labex/verify.py logout
Resultado esperado:
PASS: logout
Cerrar la sesión de la VM no elimina los recursos en la nube, por eso la eliminación se verificó primero. Tampoco cierra la sesión del navegador habitual en el Dashboard de Cloudflare.
Resumen
Diagnosticó un fallo de enrutamiento con estado sin eliminar datos que estaban sanos. El navegador reveló que pretendía abrir planning, pero seleccionó triage; el servidor rechazó de forma segura la discrepancia de la sesión firmada antes de entregar el estado; y los registros acotados confirmaron la decisión de la ruta real. Corrigió el resolvedor para que devolviera el nombre previsto validado y después demostró la recuperación del historial persistente, las actualizaciones normales después de reconectarse, el aislamiento entre nombres y el rechazo entre sesiones, tanto localmente como en Cloudflare.
La regla central de depuración es reutilizable: cuando un Agent parece vacío o no está disponible, compare la sesión prevista, el nombre de Agent seleccionado y la decisión de autorización del servidor antes de cambiar el estado. La identidad del Agent con nombre forma parte del límite de datos; no es solo una etiqueta visible.



