简介
即使数据正常,有状态应用也可能看起来像是出了问题。浏览器可能请求了错误的命名 Agent:它本应重新连接到 SupportRoutingAgent:planning,却意外打开了 SupportRoutingAgent:triage。这些名称对应不同的、由 SQLite 支持的 Durable Object 实例,因此,第一反应不应该是修改或清除状态。
在本实验中,提供的支持笔记客户端正好包含这个路由缺陷。签名令牌表明用户可以进入 planning,但客户端却选择了 triage。服务器会将签名会话与实际路由进行比较,并在交付状态前拒绝不匹配的请求。你将从以下三个层面读取证据:
- 浏览器显示预期名称和实际选择的名称;
- 有范围限制的 Worker 日志显示允许或拒绝了哪个路由;
- 独立探针显示
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 会将每个小写实例名称(例如 planning 或 triage)映射到不同的、由 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 === "&" ? "&" : 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
在 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。

刷新后,笔记和修订号保持不变,说明状态来自命名 Agent,而不是浏览器内存。
刷新后再添加一条笔记:
Confirm normal updates after reconnect
修订号会增加到 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 会话用于直观展示。下面的独立探针更具权威性,因为它还会检查重连行为,以及跨会话拒绝是否返回 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 名称只是示例。请使用你自己的 VM 生成的确切唯一名称。
打开账户的 Durable Objects 区域,找到归属于这个确切 Worker 和类的 SQLite 命名空间。不要使用示例或其他运行生成的命名空间 ID。

返回 Worker,并打开 Observability → Logs。筛选 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 名称和命名空间名称检查是否已删除,上面的只读验证器才是权威依据。
注销临时 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 的身份属于数据边界的一部分,而不只是显示标签。



