诊断路由错误的 Agent 会话

CloudflareBeginner
立即练习

简介

即使数据正常,有状态应用也可能看起来像是出了问题。浏览器可能请求了错误的命名 Agent:它本应重新连接到 SupportRoutingAgent:planning,却意外打开了 SupportRoutingAgent:triage。这些名称对应不同的、由 SQLite 支持的 Durable Object 实例,因此,第一反应不应该是修改或清除状态。

在本实验中,提供的支持笔记客户端正好包含这个路由缺陷。签名令牌表明用户可以进入 planning,但客户端却选择了 triage。服务器会将签名会话与实际路由进行比较,并在交付状态前拒绝不匹配的请求。你将从以下三个层面读取证据:

  1. 浏览器显示预期名称和实际选择的名称;
  2. 有范围限制的 Worker 日志显示允许或拒绝了哪个路由;
  3. 独立探针显示 planning 仍然保留其历史记录,而另一个命名 Agent 仍为空。

然后,你将修复路由解析器,重新连接到预期的 Agent,追加一条普通更新并刷新页面。整个过程中,原有历史记录必须保留。这是一条重要的诊断习惯:先确认路由,再操作持久数据

应用使用合成笔记,不使用语言模型。会话令牌是一个短期有效、经过 HMAC 签名的声明,其中写明允许访问的会话。它适合用于演示路由授权,但生产应用只有在验证真实用户身份后才能签发此类令牌,并且应采用更严格的密钥轮换和审计策略。

在直接进入本课程之前,请完成 将 LabEx 连接到你的 Cloudflare 账户 每个新的 LabEx VM 都需要单独进行 Wrangler 授权。课程前面的实验介绍了 Agent 身份和同步状态,但本实验会在实际使用相关功能时再次解释这些概念。

授权 VM 并为一个临时 Worker 命名

在本步骤中,你将授权新 VM,确认预期的学习账户,并声明一个名称唯一的临时 Worker。

打开终端并进入准备好的项目目录:

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

Wrangler 会打印一个 URL,并打开授权页面。确认页面显示的是你准备使用的专用 Cloudflare 学习账户,然后批准所请求的 Workers 权限。不要将密码、授权码或令牌粘贴到课程内容中。

检查结构化身份信息:

npx wrangler whoami --json

确认 loggedIn 的值为 true,并通过显示名称识别专用学习账户。在不打印账户 ID 的情况下选出它,然后生成一个唯一的临时 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 会将每个小写实例名称(例如 planningtriage)映射到不同的、由 SQLite 支持的 Durable Object。迁移会创建类命名空间,但不会预先创建所有命名实例。

如果你的专用学习账户使用其他显示名称,请先确认账户正确,然后只替换 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 === "&" ? "&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

将本地运行时作为脱离终端的进程启动:

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

在 LabEx 浏览器预览中打开打印出的 URL。两个路由卡片应显示:

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

在操作状态之前跟踪路由

在本步骤中,你将结合浏览器和服务器证据定位路由缺陷,然后只修复名称解析器。

检查负责选择名称的解析器:

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 会自动重新加载客户端。如有需要,重新打开同一个 planning URL。现在两个路由卡片都应显示 planning,状态应变为绿色,并且 Agent 应交付当前状态。

证明恢复、重连和隔离

在本步骤中,你将证明历史记录能够在重连后保留,普通更新仍可继续,并且另一个命名 Agent 保持隔离。

首次成功连接到新的 planning 实例时,页面会显示修订号 0。在页面中添加以下合成笔记:

Preserve planning history during route repair

修订号会增加到 1。刷新浏览器页面。相同的笔记和修订号必须重新出现,因为修复后的客户端会选择同一个命名 Agent,而其状态存储在 SQLite 中,不在页面中。

修复后的 planning 会话,修订号为 1

上方通过的测试使用合成笔记文本和临时的 planning 名称。你输入的笔记可以不同;重要证据是两个路由卡片显示相同名称,并且页面可见修订号 1

刷新后恢复的 planning 历史记录

刷新后,笔记和修订号保持不变,说明状态来自命名 Agent,而不是浏览器内存。

刷新后再添加一条笔记:

Confirm normal updates after reconnect

修订号会增加到 2。这可以区分两个容易混淆的问题:

重连后的普通更新使修订号增加到 2

  • 恢复:重连后,旧的历史记录是否返回?
  • 存活性:修复后的会话是否仍能接受新的普通更新?

为另一个命名 Agent 生成一个单独授权的 URL:

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

在第二个预览标签页中打开该 URL。两个路由卡片都应显示 private,修订号应为 0,并且没有笔记。即使两个实例使用同一个类,不同的命名 Agent 也不能收到 planning 的历史记录。

单独授权的 private Agent 仍为空

空的 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 会打印一个以 .workers.dev 结尾的 URL。生成新的 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 拥有 Cloudflare Durable Object 命名空间。需要匹配的是行为,而不是笔记数量的具体值。

运行独立的远程验证:

python3 .labex/verify.py deployed

预期结果:

PASS: deployed

读取 Cloudflare 证据并删除本次创建的状态

在本步骤中,你将检查范围受限的路由证据,然后只删除本次运行创建的 Worker 和 Agent 命名空间。

打开 Workers & Pages,选择名称以 labex-c11-s08- 开头的 Worker,然后打开 Settings → Bindings。确认 SupportRoutingAgent 指向 SupportRoutingAgent 类。绑定标识的是类命名空间;每个路由名称仍会在其中选择不同的实例。

已部署的 Worker 及其 SupportRoutingAgent 绑定

本次通过运行中使用的临时 Worker 名称只是示例。请使用你自己的 VM 生成的确切唯一名称。

打开账户的 Durable Objects 区域,找到归属于这个确切 Worker 和类的 SQLite 命名空间。不要使用示例或其他运行生成的命名空间 ID。

为 Agent 类创建的 SQLite Durable Object 命名空间

返回 Worker,并打开 Observability → Logs。筛选 agent_route_checked。一次有用的运行应包含针对不同诊断请求的拒绝和允许决策。事件应显示路由名称和结果,但绝不能显示令牌或笔记文本。最近日志为空并不能得出结论,因为日志摄取可能存在延迟;独立的实时探针仍然是权威依据。

Cloudflare 日志中的范围受限 agent_route_checked 事件

展开后的通过运行事件会显示请求的会话和允许结果,而 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

刷新 Dashboard 中的 Worker 和 Durable Objects 列表。确切的临时名称不应再出现。绝不要删除你未在本实验中创建的相似名称资源。

确切的临时 Worker 不再出现

通过运行创建的 Durable Object 命名空间已全部移除

这些截图展示的是清理完成后的通过运行。你的账户中可能还有无关资源;必须根据确切的 Worker 名称和命名空间名称检查是否已删除,上面的只读验证器才是权威依据。

注销临时 VM

在本步骤中,你将确认资源已清理完成,然后删除新 VM 中保存的 Wrangler 授权信息。

删除 VM 中保存的 Cloudflare 授权:

npx wrangler logout
npx wrangler whoami --json

结构化结果应包含:

{"loggedIn":false}

运行最终的独立检查:

python3 .labex/verify.py logout

预期结果:

PASS: logout

注销 VM 不会删除云端资源,因此必须先验证资源删除。注销也不会让你在普通浏览器中退出 Cloudflare Dashboard。

总结

你在不删除健康数据的情况下,诊断了一个有状态路由故障。浏览器显示它原本要打开 planning,却选择了 triage;服务器在交付状态前安全地拒绝了签名会话不匹配;范围受限的日志确认了实际路由决策。你将解析器修复为返回经过验证的预期名称,然后在本地和 Cloudflare 上证明了持久历史恢复、重连后的普通更新、不同名称之间的隔离,以及跨会话拒绝。

这条核心调试规则具有通用性:当 Agent 看起来为空或无法访问时,在修改状态之前,先比较预期会话、选择的 Agent 名称和服务器的授权决策。命名 Agent 的身份属于数据边界的一部分,而不只是显示标签。