Diagnosticar uma sessão de Agent roteada incorretamente

CloudflareBeginner
Pratique Agora

Introdução

Um aplicativo com estado pode parecer estar com defeito mesmo quando seus dados estão íntegros. O navegador pode solicitar o Agent nomeado incorreto: em vez de se reconectar a SupportRoutingAgent:planning, ele pode abrir acidentalmente SupportRoutingAgent:triage. Esses nomes selecionam instâncias diferentes de Durable Objects baseadas em SQLite. Por isso, alterar ou limpar o estado não é a resposta correta neste primeiro momento.

Neste laboratório, o cliente de anotações de suporte fornecido contém exatamente esse defeito de roteamento. Um token assinado informa que o usuário pode entrar em planning, enquanto o cliente seleciona triage. O servidor compara a sessão assinada com a rota real e rejeita a divergência antes de entregar o estado. Você lerá evidências de três camadas:

  1. o navegador mostra os nomes pretendido e selecionado;
  2. os logs limitados do Worker mostram qual rota foi permitida ou rejeitada;
  3. sondas independentes mostram que planning ainda mantém seu histórico e que outro Agent nomeado continua vazio.

Depois, você corrigirá o resolvedor de rotas, reconectará ao Agent pretendido, adicionará uma atualização normal e atualizará a página. O histórico original deverá sobreviver durante todo o processo. Este é um hábito importante de diagnóstico: identifique a rota antes de alterar dados persistentes.

O aplicativo usa anotações sintéticas e nenhum modelo de linguagem. Um token de sessão é uma declaração de curta duração, assinada com HMAC, que identifica a sessão permitida. Ele é adequado para demonstrar autorização de rotas, mas um aplicativo de produção deve emitir esses tokens somente depois de autenticar um usuário real e deve usar políticas mais rigorosas de rotação de chaves e auditoria.

Antes de entrar diretamente neste curso, conclua Conectar o LabEx à sua conta do Cloudflare. Cada nova VM do LabEx precisa de sua própria autorização do Wrangler. Os laboratórios anteriores do curso ensinam identidade do Agent e estado sincronizado, mas este laboratório explica novamente as ideias relevantes no momento em que são usadas.

Autorizar a VM e nomear um Worker descartável

Nesta etapa, você autorizará a VM recém-criada, confirmará a conta de aprendizagem pretendida e declarará um Worker descartável com nome exclusivo.

Abra um terminal e entre no projeto preparado:

cd /home/labex/project/agent-routing-diagnostics
npx wrangler login --device --browser=false

O Wrangler exibirá uma URL e abrirá uma página de autorização. Confirme que ela identifica a conta dedicada do Cloudflare que você pretende usar e, em seguida, aprove as permissões solicitadas para Workers. Nunca cole uma senha, um código de autorização ou um token no conteúdo do curso.

Inspecione o resultado estruturado da identidade:

npx wrangler whoami --json

Confirme que loggedIn é true e identifique a conta de aprendizagem dedicada pelo nome de exibição. Selecione o ID sem imprimi-lo, depois gere um nome exclusivo para o Worker descartável e uma chave de assinatura 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

Crie a configuração do 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 é tanto o binding do Worker quanto o nome da classe exportada. O SDK mapeará cada nome de instância em letras minúsculas, como planning ou triage, para um Durable Object diferente baseado em SQLite. A migração cria o namespace da classe; ela não cria antecipadamente todas as instâncias nomeadas.

Se a sua conta de aprendizagem dedicada usar outro nome de exibição, substitua apenas LabEx Learning depois de confirmar a conta pretendida. Mantenha a chave de assinatura local por enquanto; você fará o upload dela somente depois que o Worker corrigido existir:

unset SESSION_SIGNING_KEY

Execute a verificação independente de identidade e configuração:

python3 .labex/verify.py authorization

Resultado esperado:

PASS: authorization

Implementar um Agent com estado vinculado à sessão

Nesta etapa, você implementará o estado persistente das anotações e imporá o limite da sessão assinada em todas as rotas do Agent.

Crie o 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

A assinatura comprova que a declaração da sessão não foi alterada. A segunda verificação é igualmente importante: claims.session deve ser igual ao nome selecionado pela rota real do Agent. Portanto, um token válido para planning é inválido para triage.

Crie o servidor com 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

Os logs contêm deliberadamente apenas o nome da rota, a decisão, a instância, a revisão e a quantidade. Eles nunca contêm o token nem o texto das anotações. Assim, o rastro de diagnóstico continua útil sem transformar a observabilidade em um segundo vazamento de dados.

Reproduzir com segurança o sintoma do nome incorreto

Nesta etapa, você executará o cliente defeituoso fornecido e observará uma falha de autorização segura antes que qualquer estado seja entregue.

Crie o cliente de navegador fornecido:

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

Inicie o runtime local como um processo 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

Gere um token para a sessão pretendida planning e imprima uma URL de navegador:

TOKEN="$(node scripts/create-session-token.mjs planning)"
printf 'http://localhost:5173/?session=planning&token=%s\n' "$TOKEN"
unset TOKEN

Abra a URL exibida na visualização do navegador do LabEx. Os dois cartões de rota deverão mostrar:

Intended session       planning
Selected Agent name    triage

Depois de uma breve espera, o status se tornará Blocked before state delivery. O histórico continuará indisponível. Esta é uma falha segura bem-sucedida: o cliente solicitou o Agent incorreto, e o servidor o rejeitou antes de retornar o estado.

Execute a verificação determinística do sintoma:

python3 .labex/verify.py client
python3 .labex/verify.py symptom

Resultados esperados:

PASS: client
PASS: symptom

Rastrear a rota antes de alterar o estado

Nesta etapa, você combinará as evidências do navegador e do servidor para localizar o defeito de roteamento e, em seguida, corrigirá apenas o resolvedor de nomes.

Inspecione o resolvedor que escolheu o nome selecionado:

sed -n '1,120p' src/route.ts

A entrada é normalizada e validada, mas a última linha a ignora:

return "triage";

Agora inspecione apenas os eventos de roteamento locais e limitados:

grep 'agent_route_checked' .labex/vite.log | tail -5

Você deverá ver um evento semelhante a:

{"event":"agent_route_checked","requestedSession":"triage","outcome":"rejected"}

O navegador fornece a primeira metade do diagnóstico: planning era o nome pretendido, mas triage foi selecionado. O servidor fornece a segunda: triage foi rejeitado. Nenhuma das fontes isoladamente é tão clara quanto as duas juntas.

Não exclua Durable Objects, não limpe o armazenamento do navegador e não gere um token para triage. Essas ações ocultariam o defeito ou enfraqueceriam a regra de autorização. Corrija a seleção do nome:

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

O Vite recarrega o cliente automaticamente. Reabra a mesma URL de planning, se necessário. Agora os dois cartões de rota deverão exibir planning, o status deverá ficar verde e o Agent deverá entregar seu estado atual.

Comprovar a recuperação, a reconexão e o isolamento

Nesta etapa, você comprovará que o histórico sobrevive à reconexão, que as atualizações normais continuam funcionando e que outro Agent nomeado permanece isolado.

A primeira conexão bem-sucedida com uma nova instância planning exibirá a revisão 0. Adicione esta anotação sintética na página:

Preserve planning history during route repair

A revisão avançará para 1. Atualize a página do navegador. A mesma anotação e a mesma revisão deverão aparecer novamente, porque o cliente corrigido seleciona o mesmo Agent nomeado e o estado dele fica armazenado no SQLite, não na página.

A sessão planning corrigida na revisão um

A execução de teste aceita acima usa texto de anotação sintético e o nome descartável planning. A sua anotação pode ser diferente; a evidência importante é que os dois cartões de rota concordem e que a revisão 1 esteja visível.

O histórico de planning restaurado após a atualização

Após a atualização, a anotação e a revisão inalteradas mostram que o estado voltou do Agent nomeado, e não da memória do navegador.

Adicione mais uma anotação depois da atualização:

Confirm normal updates after reconnect

A revisão avançará para 2. Isso separa duas perguntas que podem ser facilmente confundidas:

Uma atualização normal após a reconexão avança para a revisão dois

  • Recuperação: o histórico antigo voltou após a reconexão?
  • Continuidade: a sessão corrigida ainda consegue aceitar uma nova atualização normal?

Gere uma URL autorizada separadamente para outro Agent nomeado:

PRIVATE_TOKEN="$(node scripts/create-session-token.mjs private)"
printf 'http://localhost:5173/?session=private&token=%s\n' "$PRIVATE_TOKEN"
unset PRIVATE_TOKEN

Abra a URL em uma segunda aba de visualização. Ela deverá mostrar private nos dois cartões de rota e a revisão 0, sem anotações. Um Agent nomeado diferente não deve receber o histórico de planning, mesmo que as duas instâncias usem a mesma classe.

Um Agent private autorizado separadamente permanece vazio

A sessão private vazia é uma evidência visual de orientação. A sonda independente abaixo continua sendo a fonte autoritativa, pois também verifica o comportamento de reconexão e uma rejeição HTTP 401 entre sessões.

Execute a sonda independente. Ela usa nomes aleatórios novos, grava uma anotação, fecha e reconecta, grava outra anotação, confirma que uma sessão separada permanece vazia e confirma que um token de outra sessão recebe HTTP 401:

npm run check
python3 .labex/verify.py repaired

Resultado esperado:

PASS: repaired

Implantar a rota corrigida

Nesta etapa, você implantará o aplicativo corrigido e repetirá a comprovação de recuperação e isolamento no Cloudflare.

Compile mais uma vez, implante exatamente o aplicativo corrigido e, depois, faça o upload da chave de assinatura local como um segredo criptografado do Worker:

npm run check
npm run deploy
npx wrangler secret bulk .dev.vars

O Wrangler exibirá uma URL terminada em .workers.dev. Gere um token planning novo e acrescente-o a essa 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

Substitua YOUR_WORKERS_SUBDOMAIN pelo subdomínio exibido na saída da implantação do Wrangler e abra a URL. Confirme que os nomes pretendido e selecionado mostram planning, depois adicione uma anotação sintética e atualize a página. O histórico remoto deverá retornar exatamente como o histórico local.

As instâncias local e remota não compartilham dados: o estado local pertence ao runtime de desenvolvimento, enquanto o Worker implantado controla um namespace de Durable Objects do Cloudflare. O comportamento, e não a quantidade literal de anotações, deverá ser igual.

Execute a comprovação remota independente:

python3 .labex/verify.py deployed

Resultado esperado:

PASS: deployed

Ler as evidências do Cloudflare e remover o estado criado

Nesta etapa, você inspecionará as evidências de roteamento limitadas e, em seguida, removerá apenas o Worker e o namespace do Agent criados nesta execução.

Abra Workers & Pages, selecione o Worker cujo nome começa com labex-c11-s08- e abra Settings → Bindings. Confirme que SupportRoutingAgent aponta para a classe SupportRoutingAgent. O binding identifica o namespace da classe; cada nome de rota ainda seleciona uma instância distinta dentro dele.

O Worker implantado e seu binding SupportRoutingAgent

O nome descartável do Worker nesta execução aceita é apenas um exemplo. Use o nome exclusivo exato gerado na sua própria VM.

Abra a área Durable Objects da conta e encontre o namespace SQLite pertencente a este Worker e a esta classe específicos. Não use o ID de namespace de um exemplo ou de outra execução.

O namespace de Durable Object SQLite criado para a classe do Agent

Volte ao Worker e abra Observability → Logs. Filtre por agent_route_checked. Uma execução útil contém decisões rejeitadas e permitidas para solicitações de diagnóstico diferentes. Os eventos deverão expor nomes de rotas e resultados, nunca tokens ou textos de anotações. Logs recentes vazios não são conclusivos, pois a ingestão pode atrasar; as sondas independentes em tempo real continuam sendo autoritativas.

Um evento limitado agent_route_checked nos logs do Cloudflare

O evento expandido da execução aceita mostra a sessão solicitada e um resultado permitido, enquanto o Cloudflare oculta o token. Os logs ajudam a explicar uma decisão, mas o verificador em tempo real continua decidindo se o roteamento e o isolamento funcionam.

Verifique o inventário de recursos pertencentes a esta execução antes de excluir qualquer coisa:

python3 .labex/verify.py observed

Resultado esperado:

PASS: observed

Exclua o namespace da classe do Agent com uma migração somente de acréscimo. Mantenha a migração v1 original e adicione 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

Excluir apenas o script do Worker não encerra explicitamente a classe do Durable Object. A migração remove primeiro o namespace da classe deste laboratório; o segundo comando remove, então, o Worker exato deste laboratório.

Comprove que os dois recursos não existem mais enquanto a autorização ainda é válida:

python3 .labex/verify.py deleted

Resultado esperado:

PASS: deleted

Atualize as listas de Workers e Durable Objects no Dashboard. Os nomes descartáveis exatos não deverão mais aparecer. Nunca exclua um recurso com nome semelhante que você não tenha criado neste laboratório.

O Worker descartável exato não aparece mais

Nenhum namespace de Durable Objects permanece da execução aceita

Estas capturas mostram a execução descartável aceita após a limpeza. Sua conta pode conter recursos não relacionados; a ausência deve ser verificada usando os nomes exatos do seu Worker e do seu namespace, e o verificador somente leitura acima é autoritativo.

Sair da VM descartável

Nesta etapa, você removerá a autorização do Wrangler armazenada na VM recém-criada depois de comprovar a limpeza dos recursos.

Remova da VM a autorização armazenada do Cloudflare:

npx wrangler logout
npx wrangler whoami --json

O resultado estruturado deverá incluir:

{"loggedIn":false}

Execute a verificação independente final:

python3 .labex/verify.py logout

Resultado esperado:

PASS: logout

Sair da VM não exclui recursos na nuvem, por isso a exclusão foi verificada primeiro. Isso também não encerra a sessão do seu navegador comum no Dashboard do Cloudflare.

Resumo

Você diagnosticou uma falha de roteamento com estado sem excluir dados íntegros. O navegador revelou que pretendia abrir planning, mas selecionou triage; o servidor rejeitou com segurança a divergência da sessão assinada antes de entregar o estado; e os logs limitados confirmaram a decisão real da rota. Você corrigiu o resolvedor para retornar o nome pretendido validado e, depois, comprovou a recuperação do histórico persistente, as atualizações normais após a reconexão, o isolamento entre nomes diferentes e a rejeição entre sessões, localmente e no Cloudflare.

A regra central de depuração é reutilizável: quando um Agent parece vazio ou indisponível, compare a sessão pretendida, o nome do Agent selecionado e a decisão de autorização do servidor antes de alterar o estado. A identidade do Agent nomeado faz parte do limite de dados; ela não é apenas um rótulo de exibição.