Введение
Состояние приложения может выглядеть повреждённым, даже если данные исправны. Браузер мог запросить неправильный именованный Agent: вместо переподключения к SupportRoutingAgent:planning он случайно открыл SupportRoutingAgent:triage. Эти имена указывают на разные экземпляры Durable Object, использующие SQLite, поэтому первым делом не следует изменять или очищать состояние.
В этой лабораторной работе предоставленный клиент заметок поддержки содержит именно такой дефект маршрутизации. Подписанный токен сообщает, что пользователь может войти в planning, а клиент выбирает triage. Сервер сравнивает подписанный сеанс с фактическим маршрутом и отклоняет несоответствие до передачи состояния. Вы изучите данные из трёх уровней:
- браузер показывает запланированное и выбранное имя;
- ограниченные журналы Worker показывают, какой маршрут был разрешён или отклонён;
- независимые проверки показывают, что
planningпо-прежнему хранит свою историю, а другой именованный Agent остаётся пустым.
Затем вы исправите определитель маршрута, подключитесь к нужному Agent, добавите обычное обновление и обновите страницу. Исходная история должна сохраниться на всех этапах. Это важная диагностическая привычка: сначала определите маршрут и только потом изменяйте долговременные данные.
Приложение использует синтетические заметки и не использует языковую модель. Токен сеанса — это краткоживущий оператор, подписанный с помощью HMAC и содержащий имя разрешённого сеанса. Он подходит для демонстрации авторизации маршрута, но в рабочем приложении такие токены следует выдавать только после аутентификации реального пользователя, а также использовать более строгие политики ротации ключей и аудита.
Перед непосредственным началом этого курса пройдите лабораторную работу Connect LabEx to Your Cloudflare Account. Для каждой новой виртуальной машины LabEx требуется отдельная авторизация Wrangler. В предыдущих лабораторных работах курса объясняются идентичность Agent и синхронизированное состояние, но здесь необходимые идеи повторно рассматриваются непосредственно в момент использования.
Авторизуйте виртуальную машину и задайте имя одноразового Worker
На этом этапе вы авторизуете новую виртуальную машину, проверите нужную учебную учётную запись и зададите уникальное имя одноразового Worker.
Откройте терминал и перейдите в подготовленный проект:
cd /home/labex/project/agent-routing-diagnostics
npx wrangler login --device --browser=false
Wrangler выведет URL и откроет страницу авторизации. Убедитесь, что на странице указана предназначенная для обучения учётная запись Cloudflare, которую вы собираетесь использовать, а затем подтвердите запрошенные разрешения Workers. Никогда не вставляйте пароль, код авторизации или токен в материалы курса.
Проверьте структурированный результат с данными об учётной записи:
npx wrangler whoami --json
Убедитесь, что loggedIn имеет значение true, и определите учебную учётную запись по её отображаемому имени. Выберите её идентификатор, не выводя его на экран, затем сгенерируйте уникальное имя одноразового Worker и локальный ключ подписи:
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
Создайте конфигурацию 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 — это одновременно привязка Worker и имя экспортируемого класса. SDK сопоставляет каждое имя экземпляра в нижнем регистре, например planning или triage, с отдельным Durable Object, использующим SQLite. Миграция создаёт пространство имён класса, но не создаёт заранее все именованные экземпляры.
Если у вашей учебной учётной записи используется другое отображаемое имя, замените только LabEx Learning, предварительно убедившись, что выбрана нужная учётная запись. Пока оставьте ключ подписи локальным: загрузите его только после создания исправленного Worker:
unset SESSION_SIGNING_KEY
Запустите независимую проверку удостоверения и конфигурации:
python3 .labex/verify.py authorization
Ожидаемый результат:
PASS: authorization
Реализуйте привязанный к сеансу Agent с сохраняемым состоянием
На этом этапе вы реализуете долговременное состояние заметок и будете проверять границу подписанного сеанса для каждого маршрута Agent.
Создайте проверку токена:
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
Подпись доказывает, что утверждение о сеансе не было изменено. Вторая проверка не менее важна: claims.session должно совпадать с именем, выбранным фактическим маршрутом Agent. Поэтому действительный токен для planning недействителен для triage.
Создайте сервер с сохраняемым состоянием:
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
В журналах намеренно содержатся только имя маршрута, решение, экземпляр, ревизия и количество элементов. Токен и текст заметки туда не попадают. Благодаря этому диагностический журнал остаётся полезным и не превращает наблюдаемость во вторую утечку данных.
Безопасно воспроизведите симптом неправильного имени
На этом этапе вы запустите предоставленный неисправный клиент и увидите безопасное отклонение авторизации до передачи каких-либо данных состояния.
Создайте предоставленный браузерный клиент:
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
Запустите локальную среду выполнения как отсоединённый процесс:
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
Создайте токен для сеанса planning и выведите URL для браузера:
TOKEN="$(node scripts/create-session-token.mjs planning)"
printf 'http://localhost:5173/?session=planning&token=%s\n' "$TOKEN"
unset TOKEN
Откройте выведенный URL в предварительном просмотре браузера LabEx. На двух карточках маршрута должны отображаться такие значения:
Intended session planning
Selected Agent name triage
Через короткое время статус изменится на Blocked before state delivery. История останется недоступной. Это ожидаемый безопасный отказ: клиент запросил неправильный Agent, и сервер отклонил запрос до возврата состояния.
Запустите детерминированную проверку симптома:
python3 .labex/verify.py client
python3 .labex/verify.py symptom
Ожидаемые результаты:
PASS: client
PASS: symptom
Проследите маршрут, прежде чем изменять состояние
На этом этапе вы объедините данные браузера и сервера, чтобы найти дефект маршрутизации, а затем исправите только определитель имени.
Проверьте определитель, который выбрал имя Agent:
sed -n '1,120p' src/route.ts
Входное значение нормализуется и проверяется, но последняя строка его игнорирует:
return "triage";
Теперь выведите только ограниченные локальные события маршрутизации:
grep 'agent_route_checked' .labex/vite.log | tail -5
Вы должны увидеть событие, похожее на это:
{"event":"agent_route_checked","requestedSession":"triage","outcome":"rejected"}
Браузер предоставляет первую часть диагноза: запланирован planning, а выбран triage. Сервер предоставляет вторую часть: triage был отклонён. Вместе эти источники дают более ясную картину, чем каждый по отдельности.
Не удаляйте Durable Objects, не очищайте хранилище браузера и не создавайте токен для triage. Эти действия скроют дефект или ослабят правило авторизации. Исправьте выбор имени:
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 автоматически перезагрузит клиент. При необходимости снова откройте тот же URL для planning. Теперь на обеих карточках маршрута должно отображаться planning, статус должен стать зелёным, а Agent должен передать текущее состояние.
Докажите восстановление, переподключение и изоляцию
На этом этапе вы докажете, что история сохраняется после переподключения, обычные обновления продолжают работать, а другой именованный Agent остаётся изолированным.
При первом успешном подключении к новому экземпляру planning на странице отображается ревизия 0. Добавьте в форму такую синтетическую заметку:
Preserve planning history during route repair
Ревизия увеличится до 1. Обновите страницу браузера. Та же заметка и та же ревизия должны появиться снова, потому что исправленный клиент выбирает тот же именованный Agent, а его состояние хранится в SQLite, а не на странице.

В принятом тестовом запуске используются синтетический текст заметки и одноразовое имя planning. Ваша заметка может отличаться; важно, чтобы обе карточки маршрута совпадали и была видна ревизия 1.

После обновления неизменившиеся заметка и ревизия показывают, что состояние было получено от именованного Agent, а не из памяти браузера.
После обновления добавьте ещё одну заметку:
Confirm normal updates after reconnect
Ревизия увеличится до 2. Это позволяет разделить два вопроса, которые легко перепутать:

- Восстановление: вернулась ли старая история после переподключения?
- Работоспособность: может ли исправленный сеанс по-прежнему принять новое обычное обновление?
Создайте отдельно авторизованный URL для другого именованного Agent:
PRIVATE_TOKEN="$(node scripts/create-session-token.mjs private)"
printf 'http://localhost:5173/?session=private&token=%s\n' "$PRIVATE_TOKEN"
unset PRIVATE_TOKEN
Откройте его во второй вкладке предварительного просмотра. В обеих карточках маршрута должно отображаться private, а ревизия должна быть 0 без заметок. Другой именованный Agent не должен получить историю planning, даже если оба экземпляра используют один и тот же класс.

Пустой сеанс private служит визуальным подтверждением. Независимая проверка ниже имеет приоритет, поскольку также проверяет поведение при переподключении и отклонение межсеансового запроса с HTTP 401.
Запустите независимую проверку. Она использует новые случайные имена, записывает одну заметку, закрывает соединение и подключается снова, записывает вторую заметку, подтверждает, что отдельный сеанс остаётся пустым, и проверяет, что токен другого сеанса получает HTTP 401:
npm run check
python3 .labex/verify.py repaired
Ожидаемый результат:
PASS: repaired
Разверните исправленный маршрут
На этом этапе вы развернёте исправленное приложение и повторите проверку восстановления и изоляции в Cloudflare.
Ещё раз выполните сборку, разверните точно исправленное приложение, а затем загрузите локальный ключ подписи как зашифрованный секрет Worker:
npm run check
npm run deploy
npx wrangler secret bulk .dev.vars
Wrangler выведет URL, оканчивающийся на .workers.dev. Создайте новый токен для planning и добавьте его к этому 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
Замените YOUR_WORKERS_SUBDOMAIN поддоменом из результата развёртывания Wrangler и откройте URL. Убедитесь, что запланированное и выбранное имена совпадают и оба равны planning, затем добавьте синтетическую заметку и обновите страницу. Удалённая история должна вернуться точно так же, как локальная.
Локальный и удалённый экземпляры не используют общие данные: локальное состояние принадлежит среде разработки, а развёрнутый Worker владеет пространством имён Durable Object в Cloudflare. Совпадать должно поведение, а не буквальное количество заметок.
Запустите независимую проверку удалённого экземпляра:
python3 .labex/verify.py deployed
Ожидаемый результат:
PASS: deployed
Изучите данные Cloudflare и удалите созданное состояние
На этом этапе вы изучите ограниченные данные о маршрутизации, а затем удалите только Worker и пространство имён Agent, созданные в рамках этого запуска.
Откройте Workers & Pages, выберите Worker, имя которого начинается с labex-c11-s08-, и откройте Settings → Bindings. Убедитесь, что SupportRoutingAgent указывает на класс SupportRoutingAgent. Привязка определяет пространство имён класса, но каждое имя маршрута по-прежнему выбирает отдельный экземпляр внутри него.

Имя одноразового Worker в этом принятом запуске приведено только в качестве примера. Используйте точное уникальное имя, сгенерированное на вашей виртуальной машине.
Откройте раздел Durable Objects учётной записи и найдите пространство имён SQLite, принадлежащее именно этому Worker и классу. Не используйте идентификатор пространства имён из примера или другого запуска.

Вернитесь к Worker и откройте Observability → Logs. Отфильтруйте записи по agent_route_checked. В полезном запуске присутствуют решения rejected и allowed для разных диагностических запросов. События должны показывать имена маршрутов и результаты, но не токены и текст заметок. Пустые последние журналы не дают однозначного вывода, поскольку доставка записей может задерживаться; независимые рабочие проверки остаются главным источником подтверждения.

В раскрытом событии принятого запуска показаны запрошенный сеанс и результат allowed, а Cloudflare скрывает токен. Журналы помогают объяснить принятое решение, но только рабочая проверка определяет, работают ли маршрутизация и изоляция.
Перед удалением проверьте инвентарь принадлежащих вам облачных ресурсов:
python3 .labex/verify.py observed
Ожидаемый результат:
PASS: observed
Удалите пространство имён класса Agent с помощью добавочной миграции. Сохраните исходную миграцию v1 и добавьте 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
Удаление только скрипта Worker не выводит пространство имён класса Durable Object из эксплуатации явно. Сначала миграция удаляет пространство имён класса этой лабораторной работы, затем вторая команда удаляет именно этот Worker.
Докажите, что оба ресурса отсутствуют, пока авторизация всё ещё действительна:
python3 .labex/verify.py deleted
Ожидаемый результат:
PASS: deleted
Обновите списки Worker и Durable Objects в Dashboard. Точные одноразовые имена больше не должны отображаться. Никогда не удаляйте ресурс с похожим именем, который вы не создавали в этой лабораторной работе.


На этих снимках показан одноразовый запуск после очистки. В вашей учётной записи могут находиться другие ресурсы; отсутствие нужно проверять по точным именам Worker и пространства имён, а приведённая выше проверка только для чтения имеет приоритет.
Выйдите из одноразовой виртуальной машины
На этом этапе вы удалите сохранённые данные авторизации Wrangler с новой виртуальной машины после подтверждения очистки ресурсов.
Удалите сохранённые в виртуальной машине данные авторизации Cloudflare:
npx wrangler logout
npx wrangler whoami --json
Структурированный результат должен содержать:
{"loggedIn":false}
Запустите финальную независимую проверку:
python3 .labex/verify.py logout
Ожидаемый результат:
PASS: logout
Выход из виртуальной машины не удаляет облачные ресурсы, поэтому удаление было проверено заранее. Он также не выполняет выход из обычного браузера в Cloudflare Dashboard.
Итоги
Вы диагностировали сбой маршрутизации приложения с состоянием, не удаляя исправные данные. Браузер показал, что приложение намеревалось открыть planning, но выбрало triage; сервер безопасно отклонил несоответствие подписанного сеанса до передачи состояния; ограниченные журналы подтвердили фактическое решение маршрутизации. Вы исправили определитель, чтобы он возвращал проверенное запланированное имя, а затем локально и в Cloudflare доказали восстановление долговременной истории, выполнение обычных обновлений после переподключения, изоляцию разных имён и отклонение межсеансового запроса.
Главное правило отладки применимо и в других ситуациях: когда Agent выглядит пустым или недоступным, сравните запланированный сеанс, выбранное имя Agent и решение сервера об авторизации, прежде чем изменять состояние. Идентичность именованного Agent — это часть границы данных, а не просто отображаемая подпись.



