diff --git a/README.md b/README.md index 4d2c08a..ca631f8 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,9 @@ git pull origin main E2B_API_KEY="your-e2b-key" # 供 MCP Server 認證使用的 API Key API_KEY="your-secure-mcp-key" +# 後台帳號(可在環境變數覆蓋);未設定時使用 wooo / 0936223270 作為維運預設值 +ADMIN_USERNAME="wooo" +ADMIN_PASSWORD="0936223270" # Scout Bot:提供 GitHub Token,可避免 API 速率限制並能真正貼上 comment GITHUB_TOKEN="github_pat_..." # 監控告警:外部導流/外部操作事件 webhook(可留空) @@ -102,3 +105,78 @@ sudo certbot --nginx -d agent.wooo.work } ``` 這樣 AI Agent 呼叫 Tool 時,就會直接連線回 110 主機上的 Next.js 閘道器了! + +### 5. 外部 A2A 生態圈探測腳本(Nostr + MCP) + +`scripts/nostr_agent_client.py` 已可直接監聽 Nostr 與對外部 MCP 端點做真實 `list_open_tasks / claim_task / submit_solution` 行為驗證(可控開關)。 + +```bash +cd /Users/ogt/Documents/agent-bounty-protocol +source venv/bin/activate + +# 1) 只做觀察(不 claim / submit) +export MCP_API_KEY="vw_beta_promo_2026" +python scripts/nostr_agent_client.py + +# 2) 允許自動 claim +export AUTO_CLAIM=true +python scripts/nostr_agent_client.py + +# 3) 允許 auto claim + submit(注意會產生可追溯的外部行為) +export AUTO_CLAIM=true +export AUTO_SUBMIT=true +export RUN_DAEMON=true +python scripts/nostr_agent_client.py +``` + +可透過環境變數延展觀測來源: + +- `EXTERNAL_MCP_ENDPOINTS`(逗號分隔,如 `https://agent.wooo.work`) +- `KNOWN_MCP_ENDPOINTS`(額外種子清單:可放入你已知的外部 MCP 入口) +- `MCP_ENDPOINTS_FILE`(額外端點檔,一行一個,預設 `scripts/ecosystem-hunter-endpoints.txt`) +- `NOSTR_RELAY_URL`(預設 `wss://relay.damus.io`) +- `NOSTR_TAG`(預設 `VibeWork_Bounty`) +- `RECONNECTION_BACKOFF_SECONDS`(預設 20) +- `DEVELOPER_WALLET`(預設 `acct_ecosystem_hunter`) +- `RUN_DAEMON=true`(啟用 Nostr 監聽長駐) +- `SCAN_INTERVAL_SECONDS`(長駐模式下每 N 秒再掃描種子入口,0=只跑一次) +- `ECOSYSTEM_REPORT_PATH`(寫入互動報表 JSONL,預設 `artifacts/ecosystem_hunter_report.jsonl`) +- `AUTO_CLAIM` / `AUTO_SUBMIT`(控制是否真的呼叫 claim/submit) +- `AUTO_SUBMIT_PR_URL`(可自訂測試用 PR URL) + +可直接抓外部真實流量快照: + +```bash +./scripts/monitor_external_traffic.sh https://agent.wooo.work 60 +``` + +#### 5.1 持續巡檢(daemon)部署到 188 主機 + +已提供可直接落地的啟動腳本與 systemd 標準化設定: + +1. 複製 `scripts/ecosystem-hunter.env.example` 成 `scripts/ecosystem-hunter.env`,填入正式金鑰與參數 +2. 把 env 與服務檔放到主機(假設 repo 在 `/home/ollama/vibework-git`) + +```bash +cp scripts/ecosystem-hunter.env.example scripts/ecosystem-hunter.env +[ -f scripts/ecosystem-hunter-endpoints.txt ] || cat <<'EOF' > scripts/ecosystem-hunter-endpoints.txt +https://agent.wooo.work +EOF +./scripts/deploy_ecosystem_hunter.sh +``` + +3. 腳本會建立 user-level systemd 並啟動服務(不需 sudo) + +```bash +systemctl --user status agent-bounty-ecosystem-hunter.service +``` + +4. 觀察巡檢輸出與 JSONL 報表 + +```bash +systemctl --user status agent-bounty-ecosystem-hunter.service +tail -f /home/ollama/vibework-git/.local/logs/agent-bounty-ecosystem-hunter/service.log +tail -f artifacts/ecosystem_hunter_report.jsonl +``` + +> 建議先以 `AUTO_CLAIM=false` 上線,確認 `list_open_tasks` 有進入外部活動後,再打開 claim/submit。 diff --git a/apps/test-agent/index.ts b/apps/test-agent/index.ts index bcc8169..10378e1 100644 --- a/apps/test-agent/index.ts +++ b/apps/test-agent/index.ts @@ -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 = { + 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); } } diff --git a/apps/web/prisma.config.ts.bak b/apps/web/prisma.config.ts.bak new file mode 100644 index 0000000..0db96e4 --- /dev/null +++ b/apps/web/prisma.config.ts.bak @@ -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, + }, +}); diff --git a/apps/web/src/app/admin/page.tsx b/apps/web/src/app/admin/page.tsx new file mode 100644 index 0000000..36d978d --- /dev/null +++ b/apps/web/src/app/admin/page.tsx @@ -0,0 +1,18 @@ +import Link from "next/link"; + +export default function AdminLandingPage() { + return ( +
+
+

VibeWork 後台

+

請使用 wooo 帳號登入後前往後台頁面。

+
+ + 前往流量監控後台 + +
+
+
+ ); +} + diff --git a/apps/web/src/app/admin/traffic/page.tsx b/apps/web/src/app/admin/traffic/page.tsx new file mode 100644 index 0000000..24e723b --- /dev/null +++ b/apps/web/src/app/admin/traffic/page.tsx @@ -0,0 +1,2 @@ +export { default } from "../../traffic/page"; + diff --git a/apps/web/src/app/api/admin/health/route.ts b/apps/web/src/app/api/admin/health/route.ts new file mode 100644 index 0000000..4a220f5 --- /dev/null +++ b/apps/web/src/app/api/admin/health/route.ts @@ -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, + }); +} + diff --git a/apps/web/src/app/api/mcp/[tool]/route.ts b/apps/web/src/app/api/mcp/[tool]/route.ts index c9da416..b539205 100644 --- a/apps/web/src/app/api/mcp/[tool]/route.ts +++ b/apps/web/src/app/api/mcp/[tool]/route.ts @@ -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; - } + }, + 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).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, diff --git a/apps/web/src/app/api/traffic/route.ts b/apps/web/src/app/api/traffic/route.ts index 526d8e4..66daaa0 100644 --- a/apps/web/src/app/api/traffic/route.ts +++ b/apps/web/src/app/api/traffic/route.ts @@ -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 | 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 | undefined { + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + return value as Record; + } + 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 = new Map(); + const externalActorClassSummary = new Map }>(); 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, diff --git a/apps/web/src/app/login/page.tsx b/apps/web/src/app/login/page.tsx new file mode 100644 index 0000000..cae3bec --- /dev/null +++ b/apps/web/src/app/login/page.tsx @@ -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; +}; + +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 ( +
+
+
+ +
+
+ + + +
+
+ +
+

Security Check

+

+ {role === "ADMIN" + ? "驗證通過,正在安全導向至管理後台..." + : "正在將您導向至登入頁面..."} +

+
+ +
+
+