誤ってルーティングされた Agent セッションを診断する

CloudflareBeginner
オンラインで実践に進む

はじめに

ステートフルアプリケーションでは、データが正常でも動作していないように見えることがあります。ブラウザが誤った 名前付き Agent を要求している可能性があるためです。SupportRoutingAgent:planning に再接続する代わりに、誤って SupportRoutingAgent:triage を開くことがあります。これらの名前は、それぞれ別の SQLite ベースの Durable Object インスタンスを選択します。そのため、最初に状態を変更したり消去したりするのは適切ではありません。

この実験では、用意されたサポートメモクライアントに、まさにこのルーティング不具合が含まれています。署名付きトークンはユーザーが planning に入れることを示していますが、クライアントは triage を選択します。サーバーは署名付きセッションと実際のルートを比較し、状態を返す前に不一致を拒否します。次の 3 つの層から証拠を読み取ります。

  1. ブラウザに、意図した名前と選択された名前が表示されます。
  2. Worker の限定されたログに、許可または拒否されたルートが表示されます。
  3. 独立したプローブにより、planning が引き続き履歴を保持していることと、別の名前付き Agent が空であることを確認します。

その後、ルートリゾルバーを修正し、意図した Agent に再接続して通常の更新を追加し、ページを更新します。この間、元の履歴は維持されなければなりません。これは重要な診断習慣です。永続データに触れる前に、ルートを特定することが大切です。

このアプリケーションでは合成メモを使用し、言語モデルは使用しません。セッショントークンは、許可されたセッション名を示す短期間有効な HMAC 署名付きステートメントです。ルート認可を説明するには適していますが、本番アプリケーションでは、実際のユーザーを認証した後にのみこのようなトークンを発行し、より強力な鍵ローテーションと監査ポリシーを使用してください。

このコースに直接入る前に、LabEx を Cloudflare アカウントに接続するを完了してください。 新しい LabEx VM では、それぞれ Wrangler の認証が必要です。コースの前半の実験では Agent の識別と同期状態を扱いますが、この実験でも必要な考え方を使用する箇所で改めて説明します。

VM を認証し、使い捨て Worker に名前を付ける

このステップでは、新しい VM を認証し、使用する学習用アカウントを確認して、一意の名前を持つ使い捨て Worker を 1 つ定義します。

ターミナルを開き、用意されたプロジェクトに移動します。

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

Wrangler は URL を表示し、認証ページを開きます。使用する専用の Cloudflare 学習用アカウント名が表示されていることを確認し、要求された Workers 権限を承認します。パスワード、認証コード、トークンをコースの内容に貼り付けないでください。

構造化された ID 情報を確認します。

npx wrangler whoami --json

loggedIntrue であることを確認し、表示名で専用の学習用アカウントを特定します。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

署名により、セッションクレームが変更されていないことを確認できます。2 つ目のチェックも同じくらい重要です。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

表示された URL を LabEx のブラウザプレビューで開きます。2 つのルートカードに次の内容が表示されます。

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 に保存されているため、同じメモとリビジョンが再び表示されなければなりません。

リビジョン 1 の修正済み planning セッション

上記の受け入れテストでは、合成メモと使い捨ての planning 名を使用しています。メモの内容は異なっていても構いません。重要な証拠は、両方のルートカードが一致し、リビジョン 1 が表示されることです。

更新後に復元された planning の履歴

更新後もメモとリビジョンが変わらないことから、状態がブラウザーのメモリではなく、名前付き Agent から返されたことが分かります。

更新後、もう 1 つメモを追加します。

Confirm normal updates after reconnect

リビジョンが 2 に進みます。これにより、混同しやすい 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

2 つ目のプレビュータブで開きます。両方のルートカードに private と表示され、メモがなく、リビジョン 0 になるはずです。同じクラスを使用していても、別の名前付き Agent が planning の履歴を受け取ってはいけません。

別途認可された private Agent が空のままの状態

空の private セッションは、画面上で確認するための補助的な証拠です。以下の独立したプローブの方が権威ある判定になります。再接続動作に加えて、セッションをまたぐ HTTP 401 拒否も確認するためです。

独立したプローブを実行します。このプローブはランダムな新しい名前を使い、メモを 1 つ書き込み、切断して再接続し、別のメモを書き込み、別セッションが空のままであることを確認します。また、セッションをまたぐトークンが HTTP 401 を受け取ることも確認します。

npm run check
python3 .labex/verify.py repaired

期待される結果:

PASS: repaired

修正済みルートをデプロイする

このステップでは、修正済みアプリケーションをデプロイし、Cloudflare 上で復旧と分離の証明を繰り返します。

もう一度ビルドし、修正済みのアプリケーションをそのままデプロイしてから、ローカルの署名鍵を暗号化された Worker Secret としてアップロードします。

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 を開きます。SupportRoutingAgentSupportRoutingAgent クラスを指していることを確認します。バインディングはクラスの名前空間を識別しますが、各ルート名はその名前空間内の別々のインスタンスを選択します。

デプロイされた Worker と SupportRoutingAgent バインディング

この受け入れ実行で使われた使い捨て Worker 名は一例にすぎません。自分の VM で生成された正確な一意の名前を使用してください。

アカウントの Durable Objects 領域を開き、この Worker とクラスが所有する SQLite 名前空間を見つけます。例や別の実行で使われた名前空間 ID は使用しないでください。

Agent クラス用に作成された SQLite Durable Object 名前空間

Worker に戻り、Observability → Logs を開きます。agent_route_checked でフィルターします。有用な実行では、異なる診断リクエストについて拒否と許可の判定が含まれます。イベントにはルート名と結果だけが表示され、トークンやメモの内容は表示されません。ログの取り込みには遅延が発生する可能性があるため、最近のログが空でも判断できません。独立したライブプローブが引き続き権威ある判定になります。

Cloudflare ログ内の限定された agent_route_checked イベント

受け入れ実行で許可されたイベントを展開すると、要求されたセッションと allowed の結果が表示され、Cloudflare がトークンをマスキングしていることを確認できます。ログは判定の説明に役立ちますが、ルーティングと分離が機能しているかどうかを判断するのはライブ検証です。

削除する前に、所有しているクラウドリソースを確認します。

python3 .labex/verify.py observed

期待される結果:

PASS: observed

append-only マイグレーションを使って 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 クラスが明示的に廃止されるわけではありません。最初のマイグレーションでこの実験のクラス名前空間を削除し、2 つ目のコマンドでこの実験の正確な 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 の識別情報は単なる表示ラベルではなく、データ境界の一部です。