소개
상태를 유지하는 애플리케이션은 데이터가 정상이어도 고장 난 것처럼 보일 수 있습니다. 브라우저가 잘못된 이름의 Agent(named Agent) 를 요청할 수 있기 때문입니다. SupportRoutingAgent:planning에 다시 연결해야 하는데 실수로 SupportRoutingAgent:triage를 열 수 있습니다. 이 이름들은 SQLite 기반 Durable Object 인스턴스를 서로 다르게 선택하므로, 상태를 변경하거나 삭제하는 것은 첫 번째 대응으로 적절하지 않습니다.
이 실습에서는 제공된 지원 메모 클라이언트에 이러한 라우팅 결함이 포함되어 있습니다. 서명된 토큰은 사용자가 planning에 들어갈 수 있다고 지정하지만, 클라이언트는 triage를 선택합니다. 서버는 서명된 세션과 실제 라우트를 비교하고, 상태를 전달하기 전에 불일치를 거부합니다. 다음 세 계층에서 증거를 확인합니다.
- 브라우저에 의도한 이름과 선택된 이름이 표시됩니다.
- 제한된 Worker 로그에 허용되거나 거부된 라우트가 표시됩니다.
- 독립 프로브를 통해
planning이 계속 기록을 보유하고 있으며 다른 이름의 Agent는 비어 있음을 확인합니다.
그런 다음 라우트 확인기를 수정하고, 의도한 Agent에 다시 연결한 뒤 일반 업데이트를 추가하고 페이지를 새로 고칩니다. 전체 과정에서 원래 기록이 유지되어야 합니다. 이는 중요한 진단 습관입니다. 영구 데이터를 다루기 전에 라우트를 확인합니다.
이 애플리케이션은 합성 메모를 사용하며 언어 모델은 사용하지 않습니다. 세션 토큰(session token) 은 허용된 세션 이름을 담은 짧은 수명의 HMAC 서명 문입니다. 라우트 권한 부여를 시연하는 데 적합하지만, 실제 운영 환경에서는 먼저 인증된 사용자에게만 이러한 토큰을 발급하고 더 강력한 키 교체 및 감사 정책을 사용해야 합니다.
이 과정을 직접 시작하기 전에 Connect LabEx to Your Cloudflare Account를 완료하세요. 새 LabEx VM마다 자체 Wrangler 인증이 필요합니다. 이전 과정에서는 Agent ID와 동기화된 상태를 다루지만, 이 실습에서는 필요한 개념을 실제 사용하는 시점에 다시 설명합니다.
VM 인증 및 일회용 Worker 이름 지정
이 단계에서는 새 VM을 인증하고, 사용할 학습 계정을 확인한 다음, 고유한 이름의 일회용 Worker를 선언합니다.
터미널을 열고 준비된 프로젝트로 이동합니다.
cd /home/labex/project/agent-routing-diagnostics
npx wrangler login --device --browser=false
Wrangler가 URL을 출력하고 인증 페이지를 엽니다. 해당 페이지에 사용하려는 전용 Cloudflare 학습 계정이 표시되는지 확인한 다음 요청된 Workers 권한을 승인합니다. 비밀번호, 인증 코드 또는 토큰을 과정 콘텐츠에 입력하지 마세요.
구조화된 ID 결과를 확인합니다.
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
독립적인 ID 및 설정 검사를 실행합니다.
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
상태를 변경하기 전에 라우트 추적
이 단계에서는 브라우저와 서버의 증거를 함께 확인해 라우팅 결함을 찾은 다음, 이름 확인기만 수정합니다.
선택된 이름을 결정한 확인기를 살펴봅니다.
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
두 번째 미리보기 탭에서 엽니다. 두 라우트 카드에 모두 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의 ID는 단순한 표시 레이블이 아니라 데이터 경계의 일부입니다.



