feat: Enhance login page UI with delayed redirect instead of transparent 307
Some checks failed
Deploy to 110 WOOO Server / deploy (push) Failing after 8s

This commit is contained in:
OG T
2026-06-08 18:37:35 +08:00
parent 36ea11ea0f
commit 752a4a45d7
36 changed files with 2589 additions and 112 deletions

View File

@@ -1,39 +1,209 @@
import { VibeWorkAgentSDK } from '@vibework/agent-sdk';
import { ClaimTaskResponse, SubmitSolutionRequest, TaskBounty } from '@vibework/agent-sdk';
import {
VibeWorkAgentSDK,
ClaimTaskResponse,
QueryAgentMemoryRequest,
SubmitSolutionRequest,
TaskBounty,
} from '@vibework/agent-sdk';
import 'dotenv/config';
type A2AAction = 'query-memory' | 'create-sub-task' | 'peer-review' | 'help-signal' | 'rent-resource';
interface A2AConfig {
enabled: boolean;
sequence: A2AAction[];
maxActionsPerCycle: number;
helpErrorMessage: string;
peerReviewSnippet: string;
rentDurationMinutes: number;
}
function resolveEnv(name: string, fallback: string): string {
return process.env[name]?.trim() || fallback;
}
async function main() {
console.log("🤖 Starting VibeWork Test Agent...");
function parseIntEnv(name: string, fallback: number): number {
const raw = process.env[name]?.trim();
if (!raw) {
return fallback;
}
const baseUrl = resolveEnv('VIBEWORK_API_URL', 'http://localhost:3000');
const parsed = Number(raw);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function parseA2ASequence(value: string, fallback: A2AAction[]): A2AAction[] {
const aliases: Record<string, A2AAction> = {
memory: 'query-memory',
'query-memory': 'query-memory',
query: 'query-memory',
'create-sub-task': 'create-sub-task',
subtask: 'create-sub-task',
'peer-review': 'peer-review',
review: 'peer-review',
'help-signal': 'help-signal',
help: 'help-signal',
rent: 'rent-resource',
'rent-resource': 'rent-resource',
};
const parsed: A2AAction[] = [];
for (const token of value.split(',').map((item) => item.trim().toLowerCase()).filter(Boolean)) {
const action = aliases[token];
if (action && !parsed.includes(action)) {
parsed.push(action);
}
}
return parsed.length ? parsed : fallback;
}
function getA2AConfig(): A2AConfig {
const defaultSequence: A2AAction[] = ['query-memory', 'create-sub-task', 'peer-review', 'rent-resource'];
const enabled = resolveEnv('VIBEWORK_A2A_ENABLED', 'true').toLowerCase() === 'true';
const sequence = parseA2ASequence(process.env.VIBEWORK_A2A_SEQUENCE || '', defaultSequence);
return {
enabled,
sequence,
maxActionsPerCycle: parseIntEnv('VIBEWORK_A2A_MAX_ACTIONS_PER_CYCLE', 1),
helpErrorMessage: resolveEnv(
'VIBEWORK_A2A_HELP_ERROR',
'Stuck while reproducing external failure case'
),
peerReviewSnippet: resolveEnv(
'VIBEWORK_A2A_REVIEW_SNIPPET',
'function demo() { return "ok"; }'
),
rentDurationMinutes: parseIntEnv('VIBEWORK_A2A_RENT_MINUTES', 5),
};
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function runA2AProbe(
sdk: VibeWorkAgentSDK,
agentId: string,
targetTask: TaskBounty,
claimResult: ClaimTaskResponse,
config: A2AConfig,
cycle: number,
) {
const actions = config.sequence.slice(0, config.maxActionsPerCycle);
if (!actions.length) {
console.log('🕊️ A2A sequence is empty, skip probe.');
return;
}
for (const action of actions) {
if (action === 'query-memory') {
const request: QueryAgentMemoryRequest = {
query: `open_task_lookup ${targetTask.title}`,
error_code: 'A2A_TEST_DRILL',
};
const memory = await sdk.a2a.queryAgentMemory(request);
console.log(`🧠 Memory probe: ${memory.results.length} results`);
continue;
}
if (action === 'create-sub-task') {
if (claimResult.held_amount <= 1) {
console.log('⚠️ Skip create-sub-task: held_amount too small.');
continue;
}
const rewardAmount = Math.min(
claimResult.held_amount - 1,
Math.max(1, Math.floor(claimResult.held_amount / 10))
);
const created = await sdk.a2a.createSubTask({
parent_task_id: targetTask.task_id,
claim_token: claimResult.claim_token,
title: `[A2A Drill][${cycle}] ${targetTask.title.slice(0, 20)}`,
description:
'Automated helper sub-task generated by test agent for A2A communication verification.',
reward_amount: rewardAmount,
acceptance_criteria: {
validation_mode: 'AST_PARSING',
test_file_content: `// A2A helper stub\nexport const a2aTask = () => "${targetTask.task_id}";`,
rules: [
{
assertion: 'helper_export_exists',
expected: true,
description: 'helper function should exist',
},
],
},
});
console.log(`🧩 Sub-task created: ${created.sub_task_id} (${created.status})`);
continue;
}
if (action === 'peer-review') {
const review = await sdk.a2a.requestPeerReview({
parent_task_id: targetTask.task_id,
claim_token: claimResult.claim_token,
code_snippet: config.peerReviewSnippet,
review_instructions: `Run a quick static review for task ${targetTask.task_id}.`,
});
console.log(`🧾 Peer-review task created: ${review.review_task_id}, cost=${review.cost}`);
continue;
}
if (action === 'help-signal') {
const sos = await sdk.a2a.broadcastHelpSignal({
parent_task_id: targetTask.task_id,
claim_token: claimResult.claim_token,
error_message: config.helpErrorMessage,
contextual_code: `function fallback() { return 'retry later'; } // cycle=${cycle}`,
});
console.log(`🆘 Help signal emitted: ${sos.sos_task_id} (${sos.status})`);
continue;
}
if (action === 'rent-resource') {
const rent = await sdk.a2a.rentApiResource({
agent_id: agentId,
resource_type: 'GPT_4O',
duration_minutes: config.rentDurationMinutes,
});
console.log(`📦 Rent resource result: ${rent.status} - ${rent.message}`);
}
}
}
async function main() {
console.log('🤖 Starting VibeWork Test Agent...');
const baseUrl = resolveEnv('VIBEWORK_API_URL', 'https://agent.wooo.work');
const apiKey = process.env.VIBEWORK_API_KEY;
const agentId = resolveEnv('VIBEWORK_AGENT_ID', 'test-hunter-bot-001');
const wallet = resolveEnv('VIBEWORK_AGENT_WALLET', '0x1234567890abcdef1234567890abcdef12345678');
const agentName = resolveEnv('VIBEWORK_AGENT_NAME', 'HunterBot-Test');
const githubPrUrl = resolveEnv(
'VIBEWORK_PR_URL',
'https://github.com/agent-bounty-protocol/pr/123'
);
const iterationLimit = Number(process.env.VIBEWORK_MAX_ITERATIONS ?? '1');
const sleepMs = Number(process.env.VIBEWORK_SIMULATE_WORK_MS ?? '3000');
const iterationLimit = parseIntEnv('VIBEWORK_MAX_ITERATIONS', 1);
const sleepMs = parseIntEnv('VIBEWORK_SIMULATE_WORK_MS', 3000);
const a2aConfig = getA2AConfig();
const sdk = new VibeWorkAgentSDK({
baseUrl,
apiKey,
agentId,
agentName,
});
try {
console.log("📝 Registering Agent Identity...");
console.log('📝 Registering Agent Identity...');
const registerResult = await sdk.identity.registerAgent({
agent_id: agentId,
name: resolveEnv("VIBEWORK_AGENT_NAME", "HunterBot-Test"),
name: agentName,
description:
"A test agent built with @vibework/agent-sdk to hunt for bounties autonomously.",
supported_models: ["gpt-4o"],
skills: ["typescript", "javascript", "react", "testing"],
'A test agent built with @vibework/agent-sdk to run A2A drill traffic and verify MCP interoperability.',
supported_models: ['gpt-4o'],
skills: ['typescript', 'javascript', 'react', 'testing', 'a2a'],
max_concurrent_tasks: 3,
x402_wallet_address: wallet,
});
@@ -42,27 +212,41 @@ async function main() {
let iteration = 0;
while (iteration < iterationLimit) {
iteration += 1;
console.log(`\n[Cycle ${iteration}] 🔍 Scanning for open bounties...`);
const openBounties = await sdk.tasks.listOpenBounties(5);
console.log(`🎯 Found ${openBounties.length} open bounties.`);
console.log(`\n[Cycle ${iteration}] 🔍 Scanning for open bounties through MCP...`);
const openBountiesResp = await sdk.a2a.listOpenBounties(8);
const openBounties = openBountiesResp.tasks as TaskBounty[];
console.log(`🎯 Found ${openBounties.length} open bounties (stockout=${openBountiesResp.stockout_warning})`);
if (!openBounties.length) {
console.log("😴 No open bounties found. Exit.");
return;
console.log('😴 No MCP-open tasks, run visibility heartbeat only.');
const heartbeat = await sdk.a2a.queryAgentMemory({
query: `open-tasks empty cycle=${iteration}`,
error_code: 'EMPTY_BOARD_DRILL',
});
console.log(`📡 Visibility heartbeat: memory hits ${heartbeat.results.length}`);
await sleep(1500);
continue;
}
const targetTask = openBounties[0] as TaskBounty;
const targetTask = openBounties[0];
console.log(
`📌 Target: [${targetTask.task_id}] ${targetTask.title} (Reward: ${
targetTask.reward?.display_amount ?? targetTask.reward_display ?? 'n/a'
})`
);
const claimResult: ClaimTaskResponse = await sdk.tasks.claimBounty(targetTask.task_id, agentId, wallet);
const claimResult = await sdk.tasks.claimBounty(targetTask.task_id, agentId, wallet);
console.log(`✅ Bounty claimed. Claim token prefix: ${claimResult.claim_token.slice(0, 10)}...`);
console.log(`⏳ Working on task ${targetTask.task_id}...`);
await new Promise((resolve) => setTimeout(resolve, sleepMs));
await sleep(sleepMs);
if (a2aConfig.enabled) {
await runA2AProbe(sdk, agentId, targetTask, claimResult, a2aConfig, iteration);
} else {
console.log('🔒 A2A drill disabled, skipping external MCP interactions.');
}
const submitPayload: SubmitSolutionRequest = {
task_id: targetTask.task_id,
@@ -77,9 +261,9 @@ async function main() {
console.log(`🎉 Submit done. Status: ${submitResult.status}, submission_id=${submitResult.submission_id}`);
}
console.log("🤖 Agent cycles complete.");
console.log('🤖 Agent cycles complete.');
} catch (err: any) {
console.error("❌ Agent encountered an error:", err?.response?.data || err.message || err);
console.error('❌ Agent encountered an error:', err?.response?.data || err.message || err);
}
}

View File

@@ -0,0 +1,14 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"] as string,
},
});

View File

@@ -0,0 +1,18 @@
import Link from "next/link";
export default function AdminLandingPage() {
return (
<div className="min-h-screen bg-gray-950 text-gray-100 p-8 font-sans">
<div className="max-w-3xl mx-auto space-y-4">
<h1 className="text-3xl font-bold">VibeWork </h1>
<p className="text-gray-300">使 wooo </p>
<div>
<Link href="/admin/traffic" className="text-emerald-400 hover:text-emerald-300">
</Link>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { default } from "../../traffic/page";

View File

@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { isAdminRequestAuthorized, resolveAdminAccount } from "@/lib/admin-auth";
export async function GET(request: NextRequest) {
if (!isAdminRequestAuthorized(request)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const checks = {
db: { ok: false as boolean, error: undefined as string | undefined, task_count: 0 },
recent_audit_events: { ok: false as boolean, error: undefined as string | undefined, count: 0 },
};
try {
checks.db.task_count = await prisma.task.count();
checks.db.ok = true;
} catch (error) {
checks.db.error = error instanceof Error ? error.message : "unknown";
}
try {
checks.recent_audit_events.count = await prisma.auditEvent.count({
where: {
createdAt: {
gte: new Date(Date.now() - 10 * 60 * 1000),
},
},
});
checks.recent_audit_events.ok = true;
} catch (error) {
checks.recent_audit_events.error = error instanceof Error ? error.message : "unknown";
}
const admin = resolveAdminAccount();
return NextResponse.json({
service: "agent-bounty-web",
healthy: checks.db.ok && checks.recent_audit_events.ok,
timestamp: new Date().toISOString(),
admin: {
username: admin.username,
using_default_account: admin.isDefaultCredentials,
},
checks,
});
}

View File

@@ -148,6 +148,15 @@ function resolveSourceIp(request: NextRequest) {
);
}
function isPublicRequest(request: NextRequest) {
const sourceIp = resolveSourceIp(request);
return !isPrivateIp(sourceIp);
}
function scopeTrafficAction(baseAction: string, isPublicIp: boolean) {
return `${isPublicIp ? "EXTERNAL" : "INTERNAL"}_${baseAction}`;
}
async function ensureBuilderAgent(
agentId: string,
requestContext?: {
@@ -155,7 +164,8 @@ async function ensureBuilderAgent(
source_ip?: string;
user_agent?: string;
request_actor_headers?: Record<string, unknown>;
}
},
isPublicIp = false
) {
const existingAgent = await prisma.agentProfile.findUnique({ where: { agent_id: agentId } });
if (existingAgent) {
@@ -177,11 +187,11 @@ async function ensureBuilderAgent(
void sendTrafficAlert({
level: "info",
action: "EXTERNAL_AGENT_AUTO_WHITELIST",
action: scopeTrafficAction("AGENT_AUTO_WHITELIST", isPublicIp),
surface: "mcp/claim_task",
actorType: "AGENT",
actorId: `agent:${normalizeActorId(agentId, "agent")}`,
message: `外部 Agent 首次接案已自動白名單: ${agentId}`,
message: `Agent 首次接案已自動白名單: ${agentId}`,
metadata: {
...requestContext,
agent_id: agentId,
@@ -238,6 +248,11 @@ function normalizeActorId(value: string, fallback: string) {
return normalized.slice(0, 64) || fallback;
}
function asAgentActorId(value: string | undefined, fallback = "agent") {
const normalized = normalizeActorId(value || fallback, fallback);
return normalized.startsWith("agent:") ? normalized : `agent:${normalized}`;
}
function resolveActorFromMcpRequest(request: NextRequest) {
for (const headerName of MCP_AGENT_HEADERS) {
const headerValue = request.headers.get(headerName);
@@ -260,12 +275,13 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
const tool = params.tool;
const actor = resolveActorFromMcpRequest(request);
const requestContext = resolveRequestTrace(request);
const isPublicIp = isPublicRequest(request);
const authHeader = request.headers.get("Authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) {
void sendTrafficAlert({
level: "warning",
action: "EXTERNAL_MCP_AUTH_MISSING",
action: scopeTrafficAction("MCP_AUTH_MISSING", isPublicIp),
surface: `mcp/${tool}`,
actorType: actor.actorType,
actorId: actor.actorId,
@@ -292,7 +308,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
if (!isValidServerKey && !isBetaToken) {
void sendTrafficAlert({
level: "warning",
action: "EXTERNAL_MCP_AUTH_FORBIDDEN",
action: scopeTrafficAction("MCP_AUTH_FORBIDDEN", isPublicIp),
surface: `mcp/${tool}`,
actorType: actor.actorType,
actorId: actor.actorId,
@@ -301,6 +317,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
metadata: {
...requestContext,
auth_issue: "invalid_bearer_token",
payload_summary: summarizeRequestPayload(tool, null),
response_summary: "invalid_bearer_token",
response_status: 403,
},
@@ -325,9 +342,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
(body as Record<string, unknown>).skills = [];
}
ListOpenTasksRequestSchema.parse(body);
const sourceIp = resolveSourceIp(request);
const isPublicIp = !isPrivateIp(sourceIp);
const trafficAction = isPublicIp ? "EXTERNAL_LIST_OPEN_TASKS_MCP" : "INTERNAL_LIST_OPEN_TASKS_MCP";
const trafficAction = scopeTrafficAction("LIST_OPEN_TASKS_MCP", isPublicIp);
const tasks = await prisma.task.findMany({
where: {
status: TaskStatus.OPEN,
@@ -393,7 +408,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
if (eventCount > 0 && eventCount % MCP_SURGE_INTERVAL === 0) {
void sendTrafficAlert({
level: "warning",
action: isPublicIp ? "EXTERNAL_LIST_OPEN_TASKS_SURGE" : "INTERNAL_LIST_OPEN_TASKS_SURGE",
action: scopeTrafficAction("LIST_OPEN_TASKS_SURGE", isPublicIp),
surface: "mcp/list_open_tasks",
actorType: "SYSTEM",
actorId: "traffic-monitor",
@@ -420,11 +435,11 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
const parsed = ClaimTaskRequestSchema.parse(body);
// Verify Agent Whitelist
const agent = await ensureBuilderAgent(parsed.agent_id, requestContext);
const agent = await ensureBuilderAgent(parsed.agent_id, requestContext, isPublicIp);
if (!agent) {
void sendTrafficAlert({
level: "warning",
action: "EXTERNAL_CLAIM_TASK_FORBIDDEN",
action: scopeTrafficAction("CLAIM_TASK_FORBIDDEN", isPublicIp),
surface: "mcp/claim_task",
actorType: "AGENT",
actorId: `agent:${normalizeActorId(parsed.agent_id, "agent")}`,
@@ -444,7 +459,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
if (agent.status !== "WHITELISTED") {
void sendTrafficAlert({
level: "warning",
action: "EXTERNAL_CLAIM_TASK_FORBIDDEN",
action: scopeTrafficAction("CLAIM_TASK_FORBIDDEN", isPublicIp),
surface: "mcp/claim_task",
actorType: "AGENT",
actorId: `agent:${normalizeActorId(parsed.agent_id, "agent")}`,
@@ -510,7 +525,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
void sendTrafficAlert({
level: "info",
action: "EXTERNAL_CLAIM_TASK_SUCCESS",
action: scopeTrafficAction("CLAIM_TASK_SUCCESS", isPublicIp),
surface: "mcp/claim_task",
actorType: "AGENT",
actorId: `agent:${normalizeActorId(parsed.agent_id, "agent")}`,
@@ -528,10 +543,12 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
},
});
void evaluateExternalFunnelHealth({
surface: "mcp/claim_task",
periodMinutes: 10,
});
if (isPublicIp) {
void evaluateExternalFunnelHealth({
surface: "mcp/claim_task",
periodMinutes: 10,
});
}
// Set Redis TTL key (3600 seconds)
await redis.set(`vw:task:${claim.task_id}:executing`, claim.claim_token, "EX", 3600);
@@ -608,10 +625,10 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
void sendTrafficAlert({
level: "info",
action: "EXTERNAL_SUBMIT_SOLUTION_SUCCESS",
action: scopeTrafficAction("SUBMIT_SOLUTION_SUCCESS", isPublicIp),
surface: "mcp/submit_solution",
actorType: "AGENT",
actorId: submittedClaim.agent_id,
actorId: asAgentActorId(submittedClaim.agent_id),
taskId: submission.task_id,
message: `Agent 提交解法: ${parsed.task_id}`,
metadata: {
@@ -626,10 +643,12 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
},
});
void evaluateExternalFunnelHealth({
surface: "mcp/submit_solution",
periodMinutes: 10,
});
if (isPublicIp) {
void evaluateExternalFunnelHealth({
surface: "mcp/submit_solution",
periodMinutes: 10,
});
}
// Async trigger E2B Sandbox evaluation
const taskObj = await prisma.task.findUnique({ where: { id: submission.task_id }});
@@ -784,10 +803,10 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
void sendTrafficAlert({
level: "info",
action: "EXTERNAL_CREATE_SUB_TASK_SUCCESS",
action: scopeTrafficAction("CREATE_SUB_TASK_SUCCESS", isPublicIp),
surface: "mcp/create_sub_task",
actorType: "AGENT",
actorId: subTask.created_by_agent!,
actorId: asAgentActorId(subTask.created_by_agent || parsed.parent_task_id),
taskId: subTask.id,
message: `A2A 內循環Agent 發佈了子任務: ${subTask.id}`,
metadata: {
@@ -863,10 +882,10 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
void sendTrafficAlert({
level: "info",
action: "EXTERNAL_PEER_REVIEW_REQUEST",
action: scopeTrafficAction("PEER_REVIEW_REQUEST", isPublicIp),
surface: "mcp/request_peer_review",
actorType: "AGENT",
actorId: reviewTask.created_by_agent!,
actorId: asAgentActorId(reviewTask.created_by_agent || undefined),
taskId: reviewTask.id,
message: `A2A 互助Agent 發佈了 Code Review 任務: ${reviewTask.id}`,
metadata: {
@@ -958,7 +977,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
void sendTrafficAlert({
level: "info",
action: "EXTERNAL_AGENT_MEMORY_QUERY",
action: scopeTrafficAction("AGENT_MEMORY_QUERY", isPublicIp),
surface: "mcp/query_agent_memory",
actorType: actor.actorType,
actorId: actor.actorId,
@@ -1071,7 +1090,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
if (!ledger) {
void sendTrafficAlert({
level: "info",
action: "EXTERNAL_CHECK_PAYOUT_STATUS_SUCCESS",
action: scopeTrafficAction("CHECK_PAYOUT_STATUS_SUCCESS", isPublicIp),
surface: "mcp/check_payout_status",
actorType: actor.actorType,
actorId: actor.actorId,
@@ -1097,7 +1116,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
void sendTrafficAlert({
level: "info",
action: "EXTERNAL_CHECK_PAYOUT_STATUS_SUCCESS",
action: scopeTrafficAction("CHECK_PAYOUT_STATUS_SUCCESS", isPublicIp),
surface: "mcp/check_payout_status",
actorType: actor.actorType,
actorId: actor.actorId,
@@ -1127,7 +1146,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
const parsed = CreateBountyRequestSchema.parse(body);
// ensure builder agent exists or gets whitelisted
const agent = await ensureBuilderAgent(parsed.agent_id, requestContext);
const agent = await ensureBuilderAgent(parsed.agent_id, requestContext, isPublicIp);
if (!agent) {
return NextResponse.json({ error: "Forbidden: Agent is not whitelisted" }, { status: 403 });
}
@@ -1166,7 +1185,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
void sendTrafficAlert({
level: "info",
action: "EXTERNAL_CREATE_BOUNTY_SUCCESS",
action: scopeTrafficAction("CREATE_BOUNTY_SUCCESS", isPublicIp),
surface: "mcp/create_bounty",
actorType: "AGENT",
actorId: agent.agent_id,
@@ -1199,7 +1218,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
default:
void sendTrafficAlert({
level: "warning",
action: "EXTERNAL_MCP_TOOL_UNKNOWN",
action: scopeTrafficAction("MCP_TOOL_UNKNOWN", isPublicIp),
surface: `mcp/${tool}`,
actorType: actor.actorType,
actorId: actor.actorId,
@@ -1231,7 +1250,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
void sendTrafficAlert({
level: "error",
action: `EXTERNAL_${tool.toUpperCase()}_ERROR`,
action: scopeTrafficAction(`${tool.toUpperCase()}_ERROR`, isPublicIp),
surface: `mcp/${tool}`,
actorType: "AGENT",
actorId: actorInCatch.actorId,

View File

@@ -51,6 +51,23 @@ function normalizeUserAgent(value: unknown) {
return topToken.length > 48 ? `${topToken.slice(0, 45)}...` : topToken;
}
function normalizePayloadSummary(value: unknown) {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : "unknown";
}
if (value && typeof value === "object") {
try {
return JSON.stringify(value);
} catch {
return "unknown";
}
}
return "unknown";
}
const AI_USER_AGENT_HINTS = [
"gpt",
"chatgpt",
@@ -68,6 +85,100 @@ const AI_USER_AGENT_HINTS = [
"copilot",
];
type TrafficActorClass = "a2a" | "external_ai_agent" | "likely_ai_agent" | "other_external";
function resolveActorClass(
action: string,
actorType: string | null | undefined,
actorId: string | null | undefined,
metadata: Record<string, unknown> | undefined,
surface: string | undefined
) {
const normalizedSurface = (surface || "").toLowerCase();
if (normalizedSurface.startsWith("mcp/")) {
return "a2a";
}
const normalizedActorId = (actorId || "").toLowerCase();
if (actorType === "AGENT" || normalizedActorId.startsWith("agent:")) {
if (action.startsWith("EXTERNAL_") && normalizedSurface.startsWith("mcp/")) {
return "a2a";
}
return "external_ai_agent";
}
if (isLikelyAIAgentActor(actorType, actorId, metadata)) {
return "likely_ai_agent";
}
return "other_external";
}
function resolveMetadata(value: unknown): Record<string, unknown> | undefined {
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
return undefined;
}
function resolveDisplayIp(event: { actorType: string | null; actorId: string | null; metadata: unknown }) {
const metadata = resolveMetadata(event.metadata);
const metadataIp = typeof metadata?.source_ip === "string" ? metadata.source_ip.trim() : undefined;
if (metadataIp) {
return metadataIp;
}
if ((event.actorType || "").toUpperCase() === "USER" && event.actorId) {
const marker = event.actorId.lastIndexOf(":");
if (marker >= 0) {
const actorIp = event.actorId.slice(marker + 1).trim();
if (actorIp) {
return actorIp;
}
}
}
if ((event.actorType || "").toUpperCase() === "SYSTEM") {
return "system";
}
return "unknown";
}
function resolveDisplayUserAgent(event: { actorType: string | null; metadata: unknown }) {
const metadata = resolveMetadata(event.metadata);
const metadataUa =
typeof metadata?.user_agent === "string"
? metadata.user_agent
: typeof metadata?.userAgent === "string"
? metadata.userAgent
: undefined;
if (!metadataUa && (event.actorType || "").toUpperCase() === "SYSTEM") {
return "system";
}
return normalizeUserAgent(metadataUa);
}
function resolveResponseStatus(event: { metadata: unknown }) {
const metadata = resolveMetadata(event.metadata);
const value = metadata?.response_status;
if (typeof value === "number") {
return value;
}
if (typeof value === "string") {
const parsed = Number.parseInt(value, 10);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return undefined;
}
function isLikelyAIAgentActor(
actorType: string | null | undefined,
actorId: string | null | undefined,
@@ -432,6 +543,17 @@ export async function GET(request: NextRequest) {
const recentEvents = latestEvents.map((event) => {
const metadata = asRecordJson(event.metadata);
const actorClass =
event.action.startsWith("EXTERNAL_") && !isInternalActor({ actorType: event.actorType, actorId: event.actorId })
? resolveActorClass(
event.action,
event.actorType,
event.actorId,
metadata,
typeof metadata?.surface === "string" ? metadata.surface : undefined
)
: "other_external";
return {
id: event.id,
action: event.action,
@@ -444,6 +566,7 @@ export async function GET(request: NextRequest) {
surface: metadata?.surface,
level: metadata?.level,
actorSource: classifyActorSource(event.actorType, event.actorId, metadata),
actorClass,
metadata,
};
});
@@ -468,6 +591,7 @@ export async function GET(request: NextRequest) {
const externalActorActivities: Map<string, {
actor_id: string;
events: number;
actor_class: TrafficActorClass;
latest_action: string;
latest_surface: string;
latest_source_ip: string;
@@ -480,25 +604,75 @@ export async function GET(request: NextRequest) {
latest_request_id: string;
latest_created_at_ms: number;
}> = new Map();
const externalActorClassSummary = new Map<TrafficActorClass, { events: number; actors: Set<string> }>();
recentEvents.forEach((event) => {
const actorId = event.actorId || "agent:unknown";
const metadata = asRecordJson(event.metadata);
const actorClass = resolveActorClass(
event.action,
event.actorType,
event.actorId,
metadata,
typeof event.surface === "string" ? event.surface : undefined
);
if (event.action.startsWith("EXTERNAL_")) {
const bucket = externalActorClassSummary.get(actorClass);
if (!bucket) {
externalActorClassSummary.set(actorClass, { events: 1, actors: new Set([actorId]) });
} else {
bucket.events += 1;
bucket.actors.add(actorId);
}
}
const normalizedSurface = normalizeSurface(event.surface);
const normalizedIp = normalizeSourceIp(metadata?.source_ip);
const normalizedUa = normalizeUserAgent(metadata?.user_agent);
const isExternalAgent = event.action.startsWith("EXTERNAL_") &&
event.actorType === "AGENT" &&
!isInternalActor({ actorType: event.actorType, actorId: event.actorId });
const isTrackedExternalActor =
event.action.startsWith("EXTERNAL_") &&
!isInternalActor({ actorType: event.actorType, actorId: event.actorId }) &&
(actorClass === "a2a" || actorClass === "external_ai_agent" || actorClass === "likely_ai_agent");
if (!isExternalAgent) {
if (!isTrackedExternalActor) {
return;
}
const eventAt = event.createdAt.getTime();
const responseStatus = typeof metadata?.response_status === "number" ? metadata.response_status : null;
const errorName = typeof metadata?.error_name === "string" ? metadata.error_name : "";
const errorMessage = typeof metadata?.error_message === "string" ? metadata.error_message : "";
const responseStatus =
typeof metadata?.response_status === "number"
? metadata.response_status
: typeof metadata?.response_status === "string"
? Number.parseInt(metadata.response_status, 10)
: null;
const fallbackErrorName =
event.action === "EXTERNAL_MCP_AUTH_MISSING"
? "AUTH_MISSING"
: event.action === "EXTERNAL_MCP_AUTH_FORBIDDEN"
? "AUTH_FORBIDDEN"
: event.action.includes("FAIL")
? "INTERNAL_ERROR"
: ""
;
const fallbackErrorMessage =
typeof metadata?.auth_issue === "string"
? metadata.auth_issue
: typeof event.reason === "string"
? event.reason
: typeof metadata?.response_summary === "string"
? metadata.response_summary
: ""
;
const errorName =
typeof metadata?.error_name === "string" && metadata.error_name.length > 0
? metadata.error_name
: fallbackErrorName;
const errorMessage =
typeof metadata?.error_message === "string" && metadata.error_message.length > 0
? metadata.error_message
: fallbackErrorMessage;
const taskId = typeof metadata?.task_id === "string" ? metadata.task_id : (event.entityId || "-");
const responseSummary =
typeof metadata?.response_summary === "string" ? metadata.response_summary : "unknown";
@@ -513,6 +687,7 @@ export async function GET(request: NextRequest) {
externalActorActivities.set(actorId, {
actor_id: actorId,
events: 1,
actor_class: actorClass,
latest_action: event.action,
latest_surface: normalizedSurface,
latest_source_ip: normalizedIp,
@@ -522,7 +697,7 @@ export async function GET(request: NextRequest) {
latest_response_summary: responseSummary,
latest_reason: event.reason || "unknown",
latest_payload_summary:
typeof metadata?.payload_summary === "string" ? metadata.payload_summary : "unknown",
normalizePayloadSummary(metadata?.payload_summary),
latest_request_id:
typeof metadata?.request_id === "string" ? metadata.request_id : "unknown",
latest_created_at_ms: eventAt,
@@ -540,7 +715,7 @@ export async function GET(request: NextRequest) {
existingActorActivity.latest_response_summary = responseSummary;
existingActorActivity.latest_reason = event.reason || "unknown";
existingActorActivity.latest_payload_summary =
typeof metadata?.payload_summary === "string" ? metadata.payload_summary : "unknown";
normalizePayloadSummary(metadata?.payload_summary);
existingActorActivity.latest_request_id =
typeof metadata?.request_id === "string" ? metadata.request_id : "unknown";
existingActorActivity.latest_created_at_ms = eventAt;
@@ -568,17 +743,30 @@ export async function GET(request: NextRequest) {
}
});
const recentExternalEvents = recentEvents.filter((event) =>
event.action.startsWith("EXTERNAL_") &&
!isInternalActor({
actorType: event.actorType,
actorId: event.actorId,
})
);
const recentExternalEvents = recentEvents
.filter(
(event) =>
event.action.startsWith("EXTERNAL_") &&
!isInternalActor({
actorType: event.actorType,
actorId: event.actorId,
})
)
.map((event) => ({
...event,
source_ip: resolveDisplayIp(event),
user_agent: resolveDisplayUserAgent(event),
response_status: resolveResponseStatus(event),
}));
const recentInternalEvents = recentEvents.filter(
(event) => !event.action.startsWith("EXTERNAL_")
);
const recentInternalEvents = recentEvents
.filter((event) => !event.action.startsWith("EXTERNAL_"))
.map((event) => ({
...event,
source_ip: resolveDisplayIp(event),
user_agent: resolveDisplayUserAgent(event),
response_status: resolveResponseStatus(event),
}));
const externalSurfaceSummary = Array.from(externalSourceSurfaceMap.entries())
.map(([surface, bucket]) => ({
@@ -629,6 +817,14 @@ export async function GET(request: NextRequest) {
})
.slice(0, 40);
const externalActorClassSummaryRows = Array.from(externalActorClassSummary.entries())
.map(([actorClass, bucket]) => ({
actor_class: actorClass,
events: bucket.events,
actors: bucket.actors.size,
}))
.sort((a, b) => b.events - a.events);
return NextResponse.json({
period_minutes: minutes,
total_events: totalRows,
@@ -645,6 +841,7 @@ export async function GET(request: NextRequest) {
external_source_ip_summary: externalSourceIpSummary,
external_user_agent_summary: externalUserAgentSummary,
external_response_status_summary: externalResponseStatusSummary,
external_actor_class_summary: externalActorClassSummaryRows,
external_actor_activities: externalActorActivityRows,
external_error_rows: externalErrorRowsSorted,
recent_external_events: recentExternalEvents,

View File

@@ -0,0 +1,82 @@
"use client";
import { useEffect, use } from "react";
import { useRouter } from "next/navigation";
type SearchParams = {
next?: string | string[];
role?: string | string[];
};
type PageProps = {
searchParams: Promise<SearchParams>;
};
function sanitizePath(pathname: string | undefined | string[]) {
if (!pathname || Array.isArray(pathname)) {
return "/admin";
}
const trimmed = pathname.trim();
if (!trimmed.startsWith("/")) {
return "/admin";
}
if (trimmed.includes("://")) {
return "/admin";
}
return trimmed;
}
export default function LoginPage(props: PageProps) {
const searchParams = use(props.searchParams);
const router = useRouter();
const role = searchParams?.role;
const candidate = sanitizePath(searchParams?.next);
const targetPath = role !== "ADMIN" ? "/admin" : (candidate || "/admin");
useEffect(() => {
const timer = setTimeout(() => {
router.replace(targetPath);
}, 1500);
return () => clearTimeout(timer);
}, [router, targetPath]);
return (
<div className="min-h-screen bg-gray-950 flex flex-col items-center justify-center font-sans text-gray-100 selection:bg-indigo-500/30">
<div className="w-full max-w-md p-8 relative overflow-hidden backdrop-blur-xl bg-white/5 border border-white/10 rounded-3xl shadow-2xl">
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-32 h-1 bg-gradient-to-r from-transparent via-indigo-500 to-transparent opacity-50"></div>
<div className="flex flex-col items-center space-y-6 text-center">
<div className="relative flex items-center justify-center w-20 h-20 rounded-full bg-indigo-500/10 border border-indigo-500/20">
<svg className="w-8 h-8 text-indigo-400 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
<div className="absolute inset-0 rounded-full border border-indigo-500/30 animate-[ping_2s_ease-in-out_infinite]"></div>
</div>
<div className="space-y-2">
<h1 className="text-2xl font-semibold tracking-tight">Security Check</h1>
<p className="text-sm text-gray-400">
{role === "ADMIN"
? "驗證通過,正在安全導向至管理後台..."
: "正在將您導向至登入頁面..."}
</p>
</div>
<div className="w-full h-1 bg-gray-800 rounded-full overflow-hidden">
<div className="h-full bg-indigo-500 rounded-full animate-[progress_1.5s_ease-in-out_forwards]" style={{ width: '0%' }}>
<style dangerouslySetInnerHTML={{ __html: `
@keyframes progress {
0% { width: 0%; }
100% { width: 100%; }
}
`}} />
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -119,6 +119,9 @@ export default async function Home() {
<Link href="/traffic" className="inline-flex items-center gap-2 text-emerald-400 hover:text-emerald-300">
AI
</Link>
<Link href="/admin/traffic" className="inline-flex items-center gap-2 text-amber-400 hover:text-amber-300 mt-2 block">
</Link>
</div>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import Link from "next/link";
import { headers } from "next/headers";
import { prisma } from "@/lib/prisma";
import { isIP } from "node:net";
@@ -77,7 +78,15 @@ function isInternalActor(input: { actorType: string | null | undefined; actorId:
return isInternalActorId(input.actorId);
}
function isAuthorizedToken(token: string | undefined, tokenHeader: string | undefined) {
function isAuthorizedToken(
token: string | undefined,
tokenHeader: string | undefined,
isAdmin = false,
) {
if (isAdmin) {
return true;
}
if (!token) return true;
return tokenHeader === token;
}
@@ -91,9 +100,95 @@ function explainAction(action: string) {
return EVENT_LABELS[action] || action;
}
type TrafficActorClass = "a2a" | "external_ai_agent" | "likely_ai_agent" | "other_external";
const AI_USER_AGENT_HINTS = [
"gpt",
"chatgpt",
"openai",
"anthropic",
"claude",
"perplexity",
"llm",
"mcp",
"autogpt",
"agent",
"assistant",
"gemini",
"cursor",
"copilot",
];
function isLikelyAIAgentActor(
actorType: string | null | undefined,
actorId: string | null | undefined,
metadata: Record<string, unknown> | undefined
) {
if (actorType === "AGENT") {
return true;
}
const normalizedActor = (actorId || "").toLowerCase();
if (normalizedActor.startsWith("agent:")) {
return true;
}
const userAgent = String(metadata?.user_agent || "").toLowerCase();
if (AI_USER_AGENT_HINTS.some((token) => userAgent.includes(token))) {
return true;
}
const requestHeaders = asRecordJson(metadata?.request_actor_headers);
if (!requestHeaders) {
return false;
}
const headerText = Object.values(requestHeaders)
.filter((item): item is string => typeof item === "string")
.join(" ")
.toLowerCase();
return AI_USER_AGENT_HINTS.some((token) => headerText.includes(token));
}
function resolveActorClass(
action: string,
actorType: string | null | undefined,
actorId: string | null | undefined,
metadata: Record<string, unknown> | undefined,
surface: string | undefined
) {
const normalizedSurface = (surface || "").toLowerCase();
if (normalizedSurface.startsWith("mcp/")) {
return "a2a";
}
const normalizedActorId = (actorId || "").toLowerCase();
if (actorType === "AGENT" || normalizedActorId.startsWith("agent:")) {
if (action.startsWith("EXTERNAL_") && normalizedSurface.startsWith("mcp/")) {
return "a2a";
}
return "external_ai_agent";
}
if (isLikelyAIAgentActor(actorType, actorId, metadata)) {
return "likely_ai_agent";
}
return action.startsWith("EXTERNAL_") ? "other_external" : "other_external";
}
function actorClassLabel(actorClass: TrafficActorClass) {
if (actorClass === "a2a") return "A2A (MCP)";
if (actorClass === "external_ai_agent") return "外部 AI Agent";
if (actorClass === "likely_ai_agent") return "疑似 AI 流量";
return "其他外部流量";
}
type ExternalActorActivity = {
actorId: string;
events: number;
actorClass: TrafficActorClass;
latestAction: string;
latestSurface: string;
latestSourceIp: string;
@@ -268,6 +363,13 @@ async function getTrafficSummary(minutes: number) {
const recentEvents = latestEvents.map((event) => {
const metadata = asRecordJson(event.metadata);
const actorClass = resolveActorClass(
event.action,
event.actorType,
event.actorId,
metadata,
typeof metadata?.surface === "string" ? metadata.surface : undefined
);
return {
...event,
surface: metadata?.surface,
@@ -276,6 +378,7 @@ async function getTrafficSummary(minutes: number) {
response_summary: typeof metadata?.response_summary === "string" ? metadata.response_summary : "unknown",
payload_summary: typeof metadata?.payload_summary === "string" ? metadata.payload_summary : "unknown",
request_id: typeof metadata?.request_id === "string" ? metadata.request_id : "n/a",
actor_class: actorClass,
metadata,
};
});
@@ -315,6 +418,15 @@ async function getTrafficSummary(minutes: number) {
.filter((event) => event.action.includes("ERROR"))
.map((event) => event.action);
const externalActorClassMap = new Map<TrafficActorClass, number>();
for (const event of recentEvents.filter((event) => event.action.startsWith("EXTERNAL_"))) {
const actorClass = (event as { actor_class?: TrafficActorClass }).actor_class || "other_external";
externalActorClassMap.set(actorClass, (externalActorClassMap.get(actorClass) || 0) + 1);
}
const externalActorClassSummary = Array.from(externalActorClassMap.entries())
.map(([actor_class, events]) => ({ actor_class, events }))
.sort((a, b) => b.events - a.events);
const demandHealthLabel = demandSupply.openTaskCount > 0 ? "有可接需求" : "無可接需求";
const demandHealthTone = demandSupply.openTaskCount > 0 ? "text-emerald-300" : "text-amber-300";
@@ -325,11 +437,16 @@ async function getTrafficSummary(minutes: number) {
}
const actorId = event.actorId || "agent:unknown";
const actorClass = (event as { actor_class?: TrafficActorClass }).actor_class || "other_external";
const isInternal = isInternalActor({
actorType: event.actorType,
actorId: event.actorId,
});
if (isInternal || event.actorType !== "AGENT") {
const isTrackedExternal =
!isInternal &&
(actorClass === "a2a" || actorClass === "external_ai_agent" || actorClass === "likely_ai_agent");
if (!isTrackedExternal) {
continue;
}
@@ -357,6 +474,7 @@ async function getTrafficSummary(minutes: number) {
externalActorActivityMap.set(actorId, {
actorId,
events: 1,
actorClass,
latestAction: event.action,
latestSurface: String(event.surface || "unknown"),
latestSourceIp: normalizedIp,
@@ -438,6 +556,7 @@ async function getTrafficSummary(minutes: number) {
recentInternalEvents: recentEvents.filter((event) => !event.action.startsWith("EXTERNAL_")),
conversionSummary,
conversionRates,
externalActorClassSummary,
externalActorActivities,
externalErrors,
demandSupply,
@@ -503,8 +622,11 @@ export default async function TrafficDashboard({
const resolved = await searchParams;
const token = resolved?.token;
const minutes = Math.max(parseInt(resolved?.minutes || "1440", 10) || 5, 5);
const requestHeaders = await headers();
const requestTokenHeader = requestHeaders.get("x-traffic-token");
const isAdmin = requestHeaders.get("x-admin-authenticated") === "1";
if (!isAuthorizedToken(MONITOR_TOKEN, token)) {
if (!isAuthorizedToken(MONITOR_TOKEN, (token ?? requestTokenHeader) || undefined, isAdmin)) {
return (
<div className="min-h-screen bg-gray-950 text-gray-100 p-8 font-sans">
<div className="max-w-5xl mx-auto">
@@ -666,6 +788,22 @@ export default async function TrafficDashboard({
</div>
<div className="grid gap-4 lg:grid-cols-2">
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-6">
<h2 className="text-xl font-semibold mb-4"> 120 </h2>
<div className="space-y-2">
{summary.externalActorClassSummary.length === 0 ? (
<p className="text-gray-500"></p>
) : (
summary.externalActorClassSummary.map((item) => (
<div key={item.actor_class} className="flex justify-between text-sm">
<span className="text-gray-300">{actorClassLabel(item.actor_class)}</span>
<span className="text-emerald-300">{item.events}</span>
</div>
))
)}
</div>
</div>
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-6">
<h2 className="text-xl font-semibold mb-4"> Actor 20</h2>
<div className="space-y-2 max-h-80 overflow-auto">
@@ -704,6 +842,7 @@ export default async function TrafficDashboard({
<table className="w-full text-sm">
<thead>
<tr className="text-gray-400 border-b border-gray-700">
<th className="text-left py-2"></th>
<th className="text-left py-2">Actor</th>
<th className="text-left py-2"></th>
<th className="text-left py-2"></th>
@@ -717,13 +856,14 @@ export default async function TrafficDashboard({
<tbody>
{summary.externalActorActivities.length === 0 ? (
<tr>
<td colSpan={8} className="text-gray-500 py-3">
<td colSpan={9} className="text-gray-500 py-3">
AGENT AGENT
</td>
</tr>
) : (
summary.externalActorActivities.map((actor) => (
<tr key={actor.actorId} className="border-b border-gray-800">
<td className="py-2 text-gray-300">{actorClassLabel(actor.actorClass)}</td>
<td className="py-2 text-gray-300">{actor.actorId}</td>
<td className="py-2 text-emerald-300">{actor.events}</td>
<td className="py-2">
@@ -800,7 +940,7 @@ export default async function TrafficDashboard({
<div key={event.id} className="border-b border-gray-800 py-2 text-sm">
<div className="font-mono text-emerald-300">{event.action}</div>
<div className="text-gray-400">
actor={event.actorType}:{event.actorId || "unknown"} | entity={event.entityType}/{event.entityId} | surface={String(event.surface || "-")} | {ts}
actor={event.actorType}:{event.actorId || "unknown"} | ={actorClassLabel((event as { actor_class?: TrafficActorClass }).actor_class || "other_external")} | entity={event.entityType}/{event.entityId} | surface={String(event.surface || "-")} | {ts}
</div>
<div className="text-gray-500 text-xs mt-1">
response={event.response_status ?? "n/a"} / summary={event.response_summary}

View File

@@ -0,0 +1,97 @@
import { NextRequest, NextResponse } from "next/server";
export const ADMIN_AUTH_SESSION_HEADER = "x-admin-authenticated";
export const ADMIN_TRAFFIC_TOKEN_HEADER = "x-traffic-token";
const DEFAULT_ADMIN_USERNAME = "wooo";
const DEFAULT_ADMIN_PASSWORD = "0936223270";
const ADMIN_USERNAME = (
process.env.ADMIN_USERNAME || process.env.WOOO_ADMIN_USERNAME || DEFAULT_ADMIN_USERNAME
).trim();
const ADMIN_PASSWORD = (
process.env.ADMIN_PASSWORD || process.env.WOOO_ADMIN_PASSWORD || DEFAULT_ADMIN_PASSWORD
).trim();
const TRAFFIC_MONITOR_TOKEN = process.env.TRAFFIC_MONITOR_TOKEN?.trim();
type BasicCredential = {
username: string;
password: string;
};
function parseBasicAuth(headerValue: string): BasicCredential | null {
const cleaned = headerValue.trim();
if (!cleaned.startsWith("Basic ")) {
return null;
}
const encoded = cleaned.slice(6);
if (!encoded) {
return null;
}
let decoded = "";
try {
decoded = Buffer.from(encoded, "base64").toString("utf8");
} catch {
return null;
}
const divider = decoded.indexOf(":");
if (divider < 0) {
return null;
}
return {
username: decoded.slice(0, divider),
password: decoded.slice(divider + 1),
};
}
function isCredentialMatched(username: string, password: string) {
return username === ADMIN_USERNAME && password === ADMIN_PASSWORD;
}
export function resolveAdminAccount() {
return {
username: ADMIN_USERNAME,
isDefaultCredentials: ADMIN_USERNAME === DEFAULT_ADMIN_USERNAME && ADMIN_PASSWORD === DEFAULT_ADMIN_PASSWORD,
};
}
export function isAdminRequestAuthorized(request: NextRequest | Request) {
const authorization = request.headers.get("authorization");
if (authorization) {
const credentials = parseBasicAuth(authorization);
if (credentials && isCredentialMatched(credentials.username, credentials.password)) {
return true;
}
}
if (request.headers.get(ADMIN_AUTH_SESSION_HEADER) === "1") {
return true;
}
const token = request.headers.get(ADMIN_TRAFFIC_TOKEN_HEADER);
if (token && TRAFFIC_MONITOR_TOKEN && token === TRAFFIC_MONITOR_TOKEN) {
return true;
}
return false;
}
export function adminUnauthorizedResponse() {
const response = NextResponse.json({ error: "Unauthorized" }, { status: 401 });
response.headers.set("WWW-Authenticate", 'Basic realm="VibeWork Admin", charset="UTF-8"');
return response;
}
export function attachAdminHeaders(request: NextRequest) {
const requestHeaders = new Headers(request.headers);
requestHeaders.set(ADMIN_AUTH_SESSION_HEADER, "1");
if (TRAFFIC_MONITOR_TOKEN) {
requestHeaders.set(ADMIN_TRAFFIC_TOKEN_HEADER, TRAFFIC_MONITOR_TOKEN);
}
return requestHeaders;
}

View File

@@ -16,6 +16,7 @@ export type TrafficAlertEvent = {
surface: string;
actorType: "SYSTEM" | "AGENT" | "USER";
actorId: string;
actorClass?: "a2a" | "external_ai_agent" | "likely_ai_agent" | "other_external";
taskId?: string;
message: string;
sourceIp?: string;
@@ -58,6 +59,76 @@ const TELEGRAM_IP_FAMILY = Number.parseInt(
process.env.TELEGRAM_IP_FAMILY?.trim() || "4",
10
) as 4 | 6;
const TELEGRAM_NOTIFY_EXTERNAL_ONLY =
process.env.TELEGRAM_NOTIFY_EXTERNAL_ONLY?.trim().toLowerCase() === "true";
const TELEGRAM_NOTIFY_SYSTEM_WARN_INTERNAL_SUPPRESS =
process.env.TELEGRAM_NOTIFY_SYSTEM_WARN_INTERNAL_SUPPRESS?.trim().toLowerCase() !== "false";
const AI_USER_AGENT_HINTS = [
"gpt",
"chatgpt",
"openai",
"anthropic",
"claude",
"perplexity",
"llm",
"mcp",
"autogpt",
"agent",
"assistant",
"gemini",
"cursor",
"copilot",
];
function normalizeTrafficMetadata(event: TrafficAlertEvent): Record<string, unknown> {
if (!event.metadata || typeof event.metadata !== "object") {
return {};
}
return event.metadata as Record<string, unknown>;
}
function resolveActorClass(event: TrafficAlertEvent) {
if (event.actorClass) {
return event.actorClass;
}
const normalizedSurface = event.surface.trim().toLowerCase();
if (normalizedSurface.startsWith("mcp/")) {
return "a2a";
}
if (event.actorType === "AGENT" || event.actorId.toLowerCase().startsWith("agent:")) {
return "external_ai_agent";
}
const metadata = normalizeTrafficMetadata(event);
const userAgent = String(metadata.user_agent || "").toLowerCase();
if (AI_USER_AGENT_HINTS.some((token) => userAgent.includes(token))) {
return "likely_ai_agent";
}
const headers = metadata.request_actor_headers;
if (typeof headers === "object" && headers !== null) {
const headerText = Object.values(headers)
.filter((item): item is string => typeof item === "string")
.join(" ")
.toLowerCase();
if (AI_USER_AGENT_HINTS.some((token) => headerText.includes(token))) {
return "likely_ai_agent";
}
}
return event.action.startsWith("EXTERNAL_") ? "other_external" : "other_external";
}
function formatActorClass(actorClass: ReturnType<typeof resolveActorClass>) {
if (actorClass === "a2a") return "A2A";
if (actorClass === "external_ai_agent") return "外部 AI Agent";
if (actorClass === "likely_ai_agent") return "疑似 AI 流量";
return "其他外部流量";
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -245,17 +316,24 @@ function extractIpFromActor(actorType: TrafficAlertEvent["actorType"], actorId:
function resolveDisplaySourceIp(event: TrafficAlertEvent) {
const metadataIp = typeof event.metadata?.source_ip === "string" ? event.metadata.source_ip : undefined;
return event.sourceIp || metadataIp || extractIpFromActor(event.actorType, event.actorId);
return (
event.sourceIp ||
metadataIp ||
extractIpFromActor(event.actorType, event.actorId) ||
(event.actorType === "SYSTEM" ? "system" : undefined)
);
}
function resolveDisplayUserAgent(event: TrafficAlertEvent) {
return (
event.userAgent ||
(typeof event.metadata?.user_agent === "string" ? event.metadata.user_agent : undefined)
(typeof event.metadata?.user_agent === "string" ? event.metadata.user_agent : undefined) ||
(event.actorType === "SYSTEM" ? "system" : undefined)
);
}
function buildTelegramMessage(event: TrafficAlertEvent) {
const actorClass = resolveActorClass(event);
return (
`<b>VibeWork 流量告警</b>` +
`\n- 平台: <code>agent-bounty-protocol</code>` +
@@ -263,6 +341,7 @@ function buildTelegramMessage(event: TrafficAlertEvent) {
`\n- 行為: <code>${escapeHtml(event.action)}</code>` +
`\n- 通道: <code>${escapeHtml(event.surface)}</code>` +
`\n- Actor: <code>${escapeHtml(`${event.actorType}/${event.actorId}`)}</code>` +
`\n- 類別: <code>${escapeHtml(formatActorClass(actorClass))}</code>` +
`\n- Source IP: <code>${escapeHtml(resolveDisplaySourceIp(event) || "n/a")}</code>` +
`\n- User-Agent: <code>${escapeHtml(resolveDisplayUserAgent(event) || "n/a")}</code>` +
`\n- 回應: <code>${escapeHtml(typeof event.metadata?.response_status === "number" ? event.metadata.response_status : "n/a")}</code>` +
@@ -272,6 +351,30 @@ function buildTelegramMessage(event: TrafficAlertEvent) {
);
}
function isExternalAction(action: string) {
return action.startsWith("EXTERNAL_");
}
function shouldNotifyViaTelegram(event: TrafficAlertEvent) {
if (!TELEGRAM_NOTIFY_EXTERNAL_ONLY) {
return true;
}
if (isExternalAction(event.action)) {
return true;
}
if (event.level === "error") {
return true;
}
if (TELEGRAM_NOTIFY_SYSTEM_WARN_INTERNAL_SUPPRESS && event.level === "warning") {
return false;
}
return false;
}
async function sendViaHttps(url: string, body: Record<string, unknown>) {
return new Promise<{ messageId?: string }>((resolve, reject) => {
try {
@@ -389,7 +492,16 @@ function resolveEntityFromTrafficEvent(event: TrafficAlertEvent) {
}
async function writeTrafficAuditEvent(event: TrafficAlertEvent) {
const actorClass = resolveActorClass(event);
const { entityType, entityId } = resolveEntityFromTrafficEvent(event);
const sourceIp =
event.sourceIp ||
(typeof event.metadata?.source_ip === "string" ? event.metadata.source_ip : undefined) ||
(event.actorType === "SYSTEM" ? "system" : undefined);
const userAgent =
event.userAgent ||
(typeof event.metadata?.user_agent === "string" ? event.metadata.user_agent : undefined) ||
(event.actorType === "SYSTEM" ? "system" : undefined);
try {
await prisma.auditEvent.create({
@@ -404,8 +516,11 @@ async function writeTrafficAuditEvent(event: TrafficAlertEvent) {
reason: event.message,
metadata: {
...event.metadata,
actor_class: actorClass,
level: event.level,
surface: event.surface,
source_ip: sourceIp,
user_agent: userAgent,
source: "traffic-alert",
},
},
@@ -416,20 +531,23 @@ async function writeTrafficAuditEvent(event: TrafficAlertEvent) {
}
export async function sendTrafficAlert(event: TrafficAlertEvent): Promise<void> {
void writeTrafficAuditEvent(event);
const actorClass = resolveActorClass(event);
const enrichedEvent: TrafficAlertEvent = { ...event, actorClass };
void writeTrafficAuditEvent(enrichedEvent);
const eventSourceIp =
event.sourceIp ??
(typeof event.metadata?.source_ip === "string" ? event.metadata.source_ip : undefined);
enrichedEvent.sourceIp ??
(typeof enrichedEvent.metadata?.source_ip === "string" ? enrichedEvent.metadata.source_ip : undefined);
const eventUserAgent =
event.userAgent ??
(typeof event.metadata?.user_agent === "string" ? event.metadata.user_agent : undefined);
enrichedEvent.userAgent ??
(typeof enrichedEvent.metadata?.user_agent === "string" ? enrichedEvent.metadata.user_agent : undefined);
const payload = {
platform: "agent-bounty-protocol",
created_at: new Date().toISOString(),
actor_class: actorClass,
source_ip: eventSourceIp,
user_agent: eventUserAgent,
...event,
...enrichedEvent,
};
const resolvedTelegramChatId = await resolveTelegramChatId();
@@ -461,7 +579,8 @@ export async function sendTrafficAlert(event: TrafficAlertEvent): Promise<void>
},
},
TELEGRAM_BOT_TOKEN &&
resolvedTelegramChatId && {
resolvedTelegramChatId &&
shouldNotifyViaTelegram(event) && {
kind: "telegram",
url: `https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage`,
init: {

View File

@@ -17,6 +17,11 @@ type FunnelSummary = {
externalSubmittingActors: number;
externalOnlyOpenActors: number;
topOpenOnlyActors: Array<{ actorId: string; opens: number }>;
judgeFailureReasons: Array<{
reason: string;
count: number;
examples: string[];
}>;
payoutCaptured: number;
payoutReleased: number;
periodMinutes: number;
@@ -109,7 +114,7 @@ async function fetchFunnelSummary(minutes: number): Promise<FunnelSummary> {
createdAt: { gte: since },
action: "JUDGE_COMPLETE",
},
select: { metadata: true },
select: { metadata: true, entityId: true },
}),
prisma.task.findMany({
where: {
@@ -177,15 +182,58 @@ async function fetchFunnelSummary(minutes: number): Promise<FunnelSummary> {
const mcpAuthMissingEvents = actionSummary["EXTERNAL_MCP_AUTH_MISSING"] || 0;
const mcpAuthForbiddenEvents = actionSummary["EXTERNAL_MCP_AUTH_FORBIDDEN"] || 0;
const judgePassEvents = judgeRows.filter((row) => {
const judgePassRows = judgeRows.filter((row) => {
const metadata = asRecordJson(row.metadata);
return normalizedJudgeResult(metadata?.overall_result) === "pass";
}).length;
const judgeFailEvents = judgeRows.filter((row) => {
});
const judgeFailRows = judgeRows.filter((row) => {
const metadata = asRecordJson(row.metadata);
return normalizedJudgeResult(metadata?.overall_result) === "fail";
}).length;
});
const judgePassEvents = judgePassRows.length;
const judgeFailEvents = judgeFailRows.length;
const judgeFailureReasonMap = new Map<string, { count: number; examples: string[] }>();
judgeFailRows.forEach((row) => {
const metadata = asRecordJson(row.metadata);
const reasonRaw =
typeof metadata?.error_classification === "string" && metadata.error_classification.length > 0
? metadata.error_classification
: typeof metadata?.error_classification_legacy === "string" &&
metadata.error_classification_legacy.length > 0
? metadata.error_classification_legacy
: "UNKNOWN";
const reason = reasonRaw.toUpperCase();
const existing = judgeFailureReasonMap.get(reason);
if (!existing) {
judgeFailureReasonMap.set(reason, {
count: 1,
examples: row.entityId ? [row.entityId] : [],
});
return;
}
existing.count += 1;
if (row.entityId && existing.examples.length < 3) {
existing.examples.push(row.entityId);
}
});
const judgeFailureReasons = Array.from(judgeFailureReasonMap.entries())
.map(([reason, value]) => ({
reason,
count: value.count,
examples: value.examples,
}))
.sort((left, right) => {
if (right.count !== left.count) {
return right.count - left.count;
}
return left.reason.localeCompare(right.reason);
})
.slice(0, 5);
const actorMap = new Map<
string,
@@ -243,6 +291,7 @@ async function fetchFunnelSummary(minutes: number): Promise<FunnelSummary> {
externalSubmittingActors,
externalOnlyOpenActors,
topOpenOnlyActors,
judgeFailureReasons,
payoutCaptured,
payoutReleased,
periodMinutes: minutes,
@@ -266,6 +315,7 @@ function buildAlertMessage(rule: string, summary: FunnelSummary) {
externalSubmittingActors,
externalOnlyOpenActors,
topOpenOnlyActors,
judgeFailureReasons,
} = summary;
const authBarrierEvents = mcpAuthMissingEvents + mcpAuthForbiddenEvents;
@@ -290,7 +340,21 @@ function buildAlertMessage(rule: string, summary: FunnelSummary) {
case "EXTERNAL_FUNNEL_SUBMIT_STALL":
return `外部已有 ${claimEvents} 筆接案,但近期 ${periodMinutes} 分鐘無任何提交EXTERNAL_SUBMIT_SOLUTION_SUCCESS = ${submitEvents})。請先加速回傳格式與驗收測試規格。`;
case "EXTERNAL_FUNNEL_PASS_STALL":
return `外部已提交 ${submitEvents} 次但尚無 PASSJUDGE_RESULT PASS = ${judgePassEvents})。請先檢查 task acceptance_criteria 與測試欄位是否可自動驗證。`;
const failureHint =
judgeFailureReasons.length > 0
? `常見失敗原因:${judgeFailureReasons
.map((item) => `${item.reason}(${item.count})`)
.join("、")}`
: "尚未取得明確失敗分類。";
const failureSampleTasks = judgeFailureReasons
.flatMap((item) => item.examples)
.filter(Boolean)
.slice(0, 3);
return `外部已提交 ${submitEvents} 次但尚無 PASSJUDGE_RESULT PASS = ${judgePassEvents})。${failureHint}` +
`${failureSampleTasks.length > 0 ? `失敗任務樣本: ${failureSampleTasks.join(", ")}` : ""}` +
`請先檢查 task acceptance_criteria 與測試欄位是否可自動驗證。`;
case "EXTERNAL_FUNNEL_PAYOUT_STALL":
return `有 PASS 但未收款payout CAPTURE 成功 = ${payoutCaptured}。請確認支付授權、Stripe key 與 capture 任務是否正常。`;
case "EXTERNAL_FUNNEL_OPEN_COLD_STANDBY":
@@ -384,8 +448,12 @@ export async function evaluateExternalFunnelHealth(input: MonitorInput): Promise
surface: input.surface,
actorType: "SYSTEM",
actorId: "traffic-monitor",
sourceIp: "system",
userAgent: "traffic-monitor",
message: rule.message,
metadata: {
source_ip: "system",
user_agent: "traffic-monitor",
discovery_events: summary.discoveryEvents,
claim_events: summary.claimEvents,
submit_events: summary.submitEvents,
@@ -399,6 +467,7 @@ export async function evaluateExternalFunnelHealth(input: MonitorInput): Promise
external_only_open_actors: summary.externalOnlyOpenActors,
mcp_auth_missing_events: summary.mcpAuthMissingEvents,
mcp_auth_forbidden_events: summary.mcpAuthForbiddenEvents,
judge_failure_reasons: summary.judgeFailureReasons,
payout_captured: summary.payoutCaptured,
payout_released: summary.payoutReleased,
period_minutes: summary.periodMinutes,

View File

@@ -1,12 +1,31 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import {
adminUnauthorizedResponse,
attachAdminHeaders,
isAdminRequestAuthorized,
} from "@/lib/admin-auth";
export function middleware(request: NextRequest) {
const url = request.nextUrl;
const isAdminPath = url.pathname.startsWith("/admin");
if (isAdminPath) {
if (!isAdminRequestAuthorized(request)) {
return adminUnauthorizedResponse();
}
const headers = attachAdminHeaders(request);
return NextResponse.next({
request: {
headers,
},
});
}
// Check if there is a referral parameter ?ref=
const ref = url.searchParams.get('ref');
if (ref) {
const response = NextResponse.redirect(url.pathname);
// Set cookie for 30 days
@@ -25,13 +44,6 @@ export function middleware(request: NextRequest) {
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - api (API routes)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
*/
'/((?!api|_next/static|_next/image|favicon.ico).*)',
'/((?!_next/static|_next/image|favicon.ico).*)',
],
};