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

@@ -21,6 +21,9 @@ git pull origin main
E2B_API_KEY="your-e2b-key" E2B_API_KEY="your-e2b-key"
# 供 MCP Server 認證使用的 API Key # 供 MCP Server 認證使用的 API Key
API_KEY="your-secure-mcp-key" API_KEY="your-secure-mcp-key"
# 後台帳號(可在環境變數覆蓋);未設定時使用 wooo / 0936223270 作為維運預設值
ADMIN_USERNAME="wooo"
ADMIN_PASSWORD="0936223270"
# Scout Bot提供 GitHub Token可避免 API 速率限制並能真正貼上 comment # Scout Bot提供 GitHub Token可避免 API 速率限制並能真正貼上 comment
GITHUB_TOKEN="github_pat_..." GITHUB_TOKEN="github_pat_..."
# 監控告警:外部導流/外部操作事件 webhook可留空 # 監控告警:外部導流/外部操作事件 webhook可留空
@@ -102,3 +105,78 @@ sudo certbot --nginx -d agent.wooo.work
} }
``` ```
這樣 AI Agent 呼叫 Tool 時,就會直接連線回 110 主機上的 Next.js 閘道器了! 這樣 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。

View File

@@ -1,39 +1,209 @@
import { VibeWorkAgentSDK } from '@vibework/agent-sdk'; import {
import { ClaimTaskResponse, SubmitSolutionRequest, TaskBounty } from '@vibework/agent-sdk'; VibeWorkAgentSDK,
ClaimTaskResponse,
QueryAgentMemoryRequest,
SubmitSolutionRequest,
TaskBounty,
} from '@vibework/agent-sdk';
import 'dotenv/config'; 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 { function resolveEnv(name: string, fallback: string): string {
return process.env[name]?.trim() || fallback; return process.env[name]?.trim() || fallback;
} }
async function main() { function parseIntEnv(name: string, fallback: number): number {
console.log("🤖 Starting VibeWork Test Agent..."); 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 apiKey = process.env.VIBEWORK_API_KEY;
const agentId = resolveEnv('VIBEWORK_AGENT_ID', 'test-hunter-bot-001'); const agentId = resolveEnv('VIBEWORK_AGENT_ID', 'test-hunter-bot-001');
const wallet = resolveEnv('VIBEWORK_AGENT_WALLET', '0x1234567890abcdef1234567890abcdef12345678'); const wallet = resolveEnv('VIBEWORK_AGENT_WALLET', '0x1234567890abcdef1234567890abcdef12345678');
const agentName = resolveEnv('VIBEWORK_AGENT_NAME', 'HunterBot-Test');
const githubPrUrl = resolveEnv( const githubPrUrl = resolveEnv(
'VIBEWORK_PR_URL', 'VIBEWORK_PR_URL',
'https://github.com/agent-bounty-protocol/pr/123' 'https://github.com/agent-bounty-protocol/pr/123'
); );
const iterationLimit = Number(process.env.VIBEWORK_MAX_ITERATIONS ?? '1'); const iterationLimit = parseIntEnv('VIBEWORK_MAX_ITERATIONS', 1);
const sleepMs = Number(process.env.VIBEWORK_SIMULATE_WORK_MS ?? '3000'); const sleepMs = parseIntEnv('VIBEWORK_SIMULATE_WORK_MS', 3000);
const a2aConfig = getA2AConfig();
const sdk = new VibeWorkAgentSDK({ const sdk = new VibeWorkAgentSDK({
baseUrl, baseUrl,
apiKey, apiKey,
agentId,
agentName,
}); });
try { try {
console.log("📝 Registering Agent Identity..."); console.log('📝 Registering Agent Identity...');
const registerResult = await sdk.identity.registerAgent({ const registerResult = await sdk.identity.registerAgent({
agent_id: agentId, agent_id: agentId,
name: resolveEnv("VIBEWORK_AGENT_NAME", "HunterBot-Test"), name: agentName,
description: description:
"A test agent built with @vibework/agent-sdk to hunt for bounties autonomously.", 'A test agent built with @vibework/agent-sdk to run A2A drill traffic and verify MCP interoperability.',
supported_models: ["gpt-4o"], supported_models: ['gpt-4o'],
skills: ["typescript", "javascript", "react", "testing"], skills: ['typescript', 'javascript', 'react', 'testing', 'a2a'],
max_concurrent_tasks: 3, max_concurrent_tasks: 3,
x402_wallet_address: wallet, x402_wallet_address: wallet,
}); });
@@ -42,27 +212,41 @@ async function main() {
let iteration = 0; let iteration = 0;
while (iteration < iterationLimit) { while (iteration < iterationLimit) {
iteration += 1; iteration += 1;
console.log(`\n[Cycle ${iteration}] 🔍 Scanning for open bounties...`); console.log(`\n[Cycle ${iteration}] 🔍 Scanning for open bounties through MCP...`);
const openBounties = await sdk.tasks.listOpenBounties(5);
console.log(`🎯 Found ${openBounties.length} open bounties.`); 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) { if (!openBounties.length) {
console.log("😴 No open bounties found. Exit."); console.log('😴 No MCP-open tasks, run visibility heartbeat only.');
return; 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( console.log(
`📌 Target: [${targetTask.task_id}] ${targetTask.title} (Reward: ${ `📌 Target: [${targetTask.task_id}] ${targetTask.title} (Reward: ${
targetTask.reward?.display_amount ?? targetTask.reward_display ?? 'n/a' 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(`✅ Bounty claimed. Claim token prefix: ${claimResult.claim_token.slice(0, 10)}...`);
console.log(`⏳ Working on task ${targetTask.task_id}...`); 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 = { const submitPayload: SubmitSolutionRequest = {
task_id: targetTask.task_id, 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(`🎉 Submit done. Status: ${submitResult.status}, submission_id=${submitResult.submission_id}`);
} }
console.log("🤖 Agent cycles complete."); console.log('🤖 Agent cycles complete.');
} catch (err: any) { } 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( async function ensureBuilderAgent(
agentId: string, agentId: string,
requestContext?: { requestContext?: {
@@ -155,7 +164,8 @@ async function ensureBuilderAgent(
source_ip?: string; source_ip?: string;
user_agent?: string; user_agent?: string;
request_actor_headers?: Record<string, unknown>; request_actor_headers?: Record<string, unknown>;
} },
isPublicIp = false
) { ) {
const existingAgent = await prisma.agentProfile.findUnique({ where: { agent_id: agentId } }); const existingAgent = await prisma.agentProfile.findUnique({ where: { agent_id: agentId } });
if (existingAgent) { if (existingAgent) {
@@ -177,11 +187,11 @@ async function ensureBuilderAgent(
void sendTrafficAlert({ void sendTrafficAlert({
level: "info", level: "info",
action: "EXTERNAL_AGENT_AUTO_WHITELIST", action: scopeTrafficAction("AGENT_AUTO_WHITELIST", isPublicIp),
surface: "mcp/claim_task", surface: "mcp/claim_task",
actorType: "AGENT", actorType: "AGENT",
actorId: `agent:${normalizeActorId(agentId, "agent")}`, actorId: `agent:${normalizeActorId(agentId, "agent")}`,
message: `外部 Agent 首次接案已自動白名單: ${agentId}`, message: `Agent 首次接案已自動白名單: ${agentId}`,
metadata: { metadata: {
...requestContext, ...requestContext,
agent_id: agentId, agent_id: agentId,
@@ -238,6 +248,11 @@ function normalizeActorId(value: string, fallback: string) {
return normalized.slice(0, 64) || fallback; 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) { function resolveActorFromMcpRequest(request: NextRequest) {
for (const headerName of MCP_AGENT_HEADERS) { for (const headerName of MCP_AGENT_HEADERS) {
const headerValue = request.headers.get(headerName); 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 tool = params.tool;
const actor = resolveActorFromMcpRequest(request); const actor = resolveActorFromMcpRequest(request);
const requestContext = resolveRequestTrace(request); const requestContext = resolveRequestTrace(request);
const isPublicIp = isPublicRequest(request);
const authHeader = request.headers.get("Authorization"); const authHeader = request.headers.get("Authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) { if (!authHeader || !authHeader.startsWith("Bearer ")) {
void sendTrafficAlert({ void sendTrafficAlert({
level: "warning", level: "warning",
action: "EXTERNAL_MCP_AUTH_MISSING", action: scopeTrafficAction("MCP_AUTH_MISSING", isPublicIp),
surface: `mcp/${tool}`, surface: `mcp/${tool}`,
actorType: actor.actorType, actorType: actor.actorType,
actorId: actor.actorId, actorId: actor.actorId,
@@ -292,7 +308,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
if (!isValidServerKey && !isBetaToken) { if (!isValidServerKey && !isBetaToken) {
void sendTrafficAlert({ void sendTrafficAlert({
level: "warning", level: "warning",
action: "EXTERNAL_MCP_AUTH_FORBIDDEN", action: scopeTrafficAction("MCP_AUTH_FORBIDDEN", isPublicIp),
surface: `mcp/${tool}`, surface: `mcp/${tool}`,
actorType: actor.actorType, actorType: actor.actorType,
actorId: actor.actorId, actorId: actor.actorId,
@@ -301,6 +317,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
metadata: { metadata: {
...requestContext, ...requestContext,
auth_issue: "invalid_bearer_token", auth_issue: "invalid_bearer_token",
payload_summary: summarizeRequestPayload(tool, null),
response_summary: "invalid_bearer_token", response_summary: "invalid_bearer_token",
response_status: 403, response_status: 403,
}, },
@@ -325,9 +342,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
(body as Record<string, unknown>).skills = []; (body as Record<string, unknown>).skills = [];
} }
ListOpenTasksRequestSchema.parse(body); ListOpenTasksRequestSchema.parse(body);
const sourceIp = resolveSourceIp(request); const trafficAction = scopeTrafficAction("LIST_OPEN_TASKS_MCP", isPublicIp);
const isPublicIp = !isPrivateIp(sourceIp);
const trafficAction = isPublicIp ? "EXTERNAL_LIST_OPEN_TASKS_MCP" : "INTERNAL_LIST_OPEN_TASKS_MCP";
const tasks = await prisma.task.findMany({ const tasks = await prisma.task.findMany({
where: { where: {
status: TaskStatus.OPEN, 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) { if (eventCount > 0 && eventCount % MCP_SURGE_INTERVAL === 0) {
void sendTrafficAlert({ void sendTrafficAlert({
level: "warning", 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", surface: "mcp/list_open_tasks",
actorType: "SYSTEM", actorType: "SYSTEM",
actorId: "traffic-monitor", actorId: "traffic-monitor",
@@ -420,11 +435,11 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
const parsed = ClaimTaskRequestSchema.parse(body); const parsed = ClaimTaskRequestSchema.parse(body);
// Verify Agent Whitelist // Verify Agent Whitelist
const agent = await ensureBuilderAgent(parsed.agent_id, requestContext); const agent = await ensureBuilderAgent(parsed.agent_id, requestContext, isPublicIp);
if (!agent) { if (!agent) {
void sendTrafficAlert({ void sendTrafficAlert({
level: "warning", level: "warning",
action: "EXTERNAL_CLAIM_TASK_FORBIDDEN", action: scopeTrafficAction("CLAIM_TASK_FORBIDDEN", isPublicIp),
surface: "mcp/claim_task", surface: "mcp/claim_task",
actorType: "AGENT", actorType: "AGENT",
actorId: `agent:${normalizeActorId(parsed.agent_id, "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") { if (agent.status !== "WHITELISTED") {
void sendTrafficAlert({ void sendTrafficAlert({
level: "warning", level: "warning",
action: "EXTERNAL_CLAIM_TASK_FORBIDDEN", action: scopeTrafficAction("CLAIM_TASK_FORBIDDEN", isPublicIp),
surface: "mcp/claim_task", surface: "mcp/claim_task",
actorType: "AGENT", actorType: "AGENT",
actorId: `agent:${normalizeActorId(parsed.agent_id, "agent")}`, actorId: `agent:${normalizeActorId(parsed.agent_id, "agent")}`,
@@ -510,7 +525,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
void sendTrafficAlert({ void sendTrafficAlert({
level: "info", level: "info",
action: "EXTERNAL_CLAIM_TASK_SUCCESS", action: scopeTrafficAction("CLAIM_TASK_SUCCESS", isPublicIp),
surface: "mcp/claim_task", surface: "mcp/claim_task",
actorType: "AGENT", actorType: "AGENT",
actorId: `agent:${normalizeActorId(parsed.agent_id, "agent")}`, actorId: `agent:${normalizeActorId(parsed.agent_id, "agent")}`,
@@ -528,10 +543,12 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
}, },
}); });
void evaluateExternalFunnelHealth({ if (isPublicIp) {
surface: "mcp/claim_task", void evaluateExternalFunnelHealth({
periodMinutes: 10, surface: "mcp/claim_task",
}); periodMinutes: 10,
});
}
// Set Redis TTL key (3600 seconds) // Set Redis TTL key (3600 seconds)
await redis.set(`vw:task:${claim.task_id}:executing`, claim.claim_token, "EX", 3600); 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({ void sendTrafficAlert({
level: "info", level: "info",
action: "EXTERNAL_SUBMIT_SOLUTION_SUCCESS", action: scopeTrafficAction("SUBMIT_SOLUTION_SUCCESS", isPublicIp),
surface: "mcp/submit_solution", surface: "mcp/submit_solution",
actorType: "AGENT", actorType: "AGENT",
actorId: submittedClaim.agent_id, actorId: asAgentActorId(submittedClaim.agent_id),
taskId: submission.task_id, taskId: submission.task_id,
message: `Agent 提交解法: ${parsed.task_id}`, message: `Agent 提交解法: ${parsed.task_id}`,
metadata: { metadata: {
@@ -626,10 +643,12 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
}, },
}); });
void evaluateExternalFunnelHealth({ if (isPublicIp) {
surface: "mcp/submit_solution", void evaluateExternalFunnelHealth({
periodMinutes: 10, surface: "mcp/submit_solution",
}); periodMinutes: 10,
});
}
// Async trigger E2B Sandbox evaluation // Async trigger E2B Sandbox evaluation
const taskObj = await prisma.task.findUnique({ where: { id: submission.task_id }}); 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({ void sendTrafficAlert({
level: "info", level: "info",
action: "EXTERNAL_CREATE_SUB_TASK_SUCCESS", action: scopeTrafficAction("CREATE_SUB_TASK_SUCCESS", isPublicIp),
surface: "mcp/create_sub_task", surface: "mcp/create_sub_task",
actorType: "AGENT", actorType: "AGENT",
actorId: subTask.created_by_agent!, actorId: asAgentActorId(subTask.created_by_agent || parsed.parent_task_id),
taskId: subTask.id, taskId: subTask.id,
message: `A2A 內循環Agent 發佈了子任務: ${subTask.id}`, message: `A2A 內循環Agent 發佈了子任務: ${subTask.id}`,
metadata: { metadata: {
@@ -863,10 +882,10 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
void sendTrafficAlert({ void sendTrafficAlert({
level: "info", level: "info",
action: "EXTERNAL_PEER_REVIEW_REQUEST", action: scopeTrafficAction("PEER_REVIEW_REQUEST", isPublicIp),
surface: "mcp/request_peer_review", surface: "mcp/request_peer_review",
actorType: "AGENT", actorType: "AGENT",
actorId: reviewTask.created_by_agent!, actorId: asAgentActorId(reviewTask.created_by_agent || undefined),
taskId: reviewTask.id, taskId: reviewTask.id,
message: `A2A 互助Agent 發佈了 Code Review 任務: ${reviewTask.id}`, message: `A2A 互助Agent 發佈了 Code Review 任務: ${reviewTask.id}`,
metadata: { metadata: {
@@ -958,7 +977,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
void sendTrafficAlert({ void sendTrafficAlert({
level: "info", level: "info",
action: "EXTERNAL_AGENT_MEMORY_QUERY", action: scopeTrafficAction("AGENT_MEMORY_QUERY", isPublicIp),
surface: "mcp/query_agent_memory", surface: "mcp/query_agent_memory",
actorType: actor.actorType, actorType: actor.actorType,
actorId: actor.actorId, actorId: actor.actorId,
@@ -1071,7 +1090,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
if (!ledger) { if (!ledger) {
void sendTrafficAlert({ void sendTrafficAlert({
level: "info", level: "info",
action: "EXTERNAL_CHECK_PAYOUT_STATUS_SUCCESS", action: scopeTrafficAction("CHECK_PAYOUT_STATUS_SUCCESS", isPublicIp),
surface: "mcp/check_payout_status", surface: "mcp/check_payout_status",
actorType: actor.actorType, actorType: actor.actorType,
actorId: actor.actorId, actorId: actor.actorId,
@@ -1097,7 +1116,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
void sendTrafficAlert({ void sendTrafficAlert({
level: "info", level: "info",
action: "EXTERNAL_CHECK_PAYOUT_STATUS_SUCCESS", action: scopeTrafficAction("CHECK_PAYOUT_STATUS_SUCCESS", isPublicIp),
surface: "mcp/check_payout_status", surface: "mcp/check_payout_status",
actorType: actor.actorType, actorType: actor.actorType,
actorId: actor.actorId, actorId: actor.actorId,
@@ -1127,7 +1146,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
const parsed = CreateBountyRequestSchema.parse(body); const parsed = CreateBountyRequestSchema.parse(body);
// ensure builder agent exists or gets whitelisted // 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) { if (!agent) {
return NextResponse.json({ error: "Forbidden: Agent is not whitelisted" }, { status: 403 }); 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({ void sendTrafficAlert({
level: "info", level: "info",
action: "EXTERNAL_CREATE_BOUNTY_SUCCESS", action: scopeTrafficAction("CREATE_BOUNTY_SUCCESS", isPublicIp),
surface: "mcp/create_bounty", surface: "mcp/create_bounty",
actorType: "AGENT", actorType: "AGENT",
actorId: agent.agent_id, actorId: agent.agent_id,
@@ -1199,7 +1218,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
default: default:
void sendTrafficAlert({ void sendTrafficAlert({
level: "warning", level: "warning",
action: "EXTERNAL_MCP_TOOL_UNKNOWN", action: scopeTrafficAction("MCP_TOOL_UNKNOWN", isPublicIp),
surface: `mcp/${tool}`, surface: `mcp/${tool}`,
actorType: actor.actorType, actorType: actor.actorType,
actorId: actor.actorId, actorId: actor.actorId,
@@ -1231,7 +1250,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
void sendTrafficAlert({ void sendTrafficAlert({
level: "error", level: "error",
action: `EXTERNAL_${tool.toUpperCase()}_ERROR`, action: scopeTrafficAction(`${tool.toUpperCase()}_ERROR`, isPublicIp),
surface: `mcp/${tool}`, surface: `mcp/${tool}`,
actorType: "AGENT", actorType: "AGENT",
actorId: actorInCatch.actorId, actorId: actorInCatch.actorId,

View File

@@ -51,6 +51,23 @@ function normalizeUserAgent(value: unknown) {
return topToken.length > 48 ? `${topToken.slice(0, 45)}...` : topToken; 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 = [ const AI_USER_AGENT_HINTS = [
"gpt", "gpt",
"chatgpt", "chatgpt",
@@ -68,6 +85,100 @@ const AI_USER_AGENT_HINTS = [
"copilot", "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( function isLikelyAIAgentActor(
actorType: string | null | undefined, actorType: string | null | undefined,
actorId: string | null | undefined, actorId: string | null | undefined,
@@ -432,6 +543,17 @@ export async function GET(request: NextRequest) {
const recentEvents = latestEvents.map((event) => { const recentEvents = latestEvents.map((event) => {
const metadata = asRecordJson(event.metadata); 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 { return {
id: event.id, id: event.id,
action: event.action, action: event.action,
@@ -444,6 +566,7 @@ export async function GET(request: NextRequest) {
surface: metadata?.surface, surface: metadata?.surface,
level: metadata?.level, level: metadata?.level,
actorSource: classifyActorSource(event.actorType, event.actorId, metadata), actorSource: classifyActorSource(event.actorType, event.actorId, metadata),
actorClass,
metadata, metadata,
}; };
}); });
@@ -468,6 +591,7 @@ export async function GET(request: NextRequest) {
const externalActorActivities: Map<string, { const externalActorActivities: Map<string, {
actor_id: string; actor_id: string;
events: number; events: number;
actor_class: TrafficActorClass;
latest_action: string; latest_action: string;
latest_surface: string; latest_surface: string;
latest_source_ip: string; latest_source_ip: string;
@@ -480,25 +604,75 @@ export async function GET(request: NextRequest) {
latest_request_id: string; latest_request_id: string;
latest_created_at_ms: number; latest_created_at_ms: number;
}> = new Map(); }> = new Map();
const externalActorClassSummary = new Map<TrafficActorClass, { events: number; actors: Set<string> }>();
recentEvents.forEach((event) => { recentEvents.forEach((event) => {
const actorId = event.actorId || "agent:unknown"; const actorId = event.actorId || "agent:unknown";
const metadata = asRecordJson(event.metadata); 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 normalizedSurface = normalizeSurface(event.surface);
const normalizedIp = normalizeSourceIp(metadata?.source_ip); const normalizedIp = normalizeSourceIp(metadata?.source_ip);
const normalizedUa = normalizeUserAgent(metadata?.user_agent); const normalizedUa = normalizeUserAgent(metadata?.user_agent);
const isExternalAgent = event.action.startsWith("EXTERNAL_") && const isTrackedExternalActor =
event.actorType === "AGENT" && event.action.startsWith("EXTERNAL_") &&
!isInternalActor({ actorType: event.actorType, actorId: event.actorId }); !isInternalActor({ actorType: event.actorType, actorId: event.actorId }) &&
(actorClass === "a2a" || actorClass === "external_ai_agent" || actorClass === "likely_ai_agent");
if (!isExternalAgent) { if (!isTrackedExternalActor) {
return; return;
} }
const eventAt = event.createdAt.getTime(); const eventAt = event.createdAt.getTime();
const responseStatus = typeof metadata?.response_status === "number" ? metadata.response_status : null; const responseStatus =
const errorName = typeof metadata?.error_name === "string" ? metadata.error_name : ""; typeof metadata?.response_status === "number"
const errorMessage = typeof metadata?.error_message === "string" ? metadata.error_message : ""; ? 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 taskId = typeof metadata?.task_id === "string" ? metadata.task_id : (event.entityId || "-");
const responseSummary = const responseSummary =
typeof metadata?.response_summary === "string" ? metadata.response_summary : "unknown"; typeof metadata?.response_summary === "string" ? metadata.response_summary : "unknown";
@@ -513,6 +687,7 @@ export async function GET(request: NextRequest) {
externalActorActivities.set(actorId, { externalActorActivities.set(actorId, {
actor_id: actorId, actor_id: actorId,
events: 1, events: 1,
actor_class: actorClass,
latest_action: event.action, latest_action: event.action,
latest_surface: normalizedSurface, latest_surface: normalizedSurface,
latest_source_ip: normalizedIp, latest_source_ip: normalizedIp,
@@ -522,7 +697,7 @@ export async function GET(request: NextRequest) {
latest_response_summary: responseSummary, latest_response_summary: responseSummary,
latest_reason: event.reason || "unknown", latest_reason: event.reason || "unknown",
latest_payload_summary: latest_payload_summary:
typeof metadata?.payload_summary === "string" ? metadata.payload_summary : "unknown", normalizePayloadSummary(metadata?.payload_summary),
latest_request_id: latest_request_id:
typeof metadata?.request_id === "string" ? metadata.request_id : "unknown", typeof metadata?.request_id === "string" ? metadata.request_id : "unknown",
latest_created_at_ms: eventAt, latest_created_at_ms: eventAt,
@@ -540,7 +715,7 @@ export async function GET(request: NextRequest) {
existingActorActivity.latest_response_summary = responseSummary; existingActorActivity.latest_response_summary = responseSummary;
existingActorActivity.latest_reason = event.reason || "unknown"; existingActorActivity.latest_reason = event.reason || "unknown";
existingActorActivity.latest_payload_summary = existingActorActivity.latest_payload_summary =
typeof metadata?.payload_summary === "string" ? metadata.payload_summary : "unknown"; normalizePayloadSummary(metadata?.payload_summary);
existingActorActivity.latest_request_id = existingActorActivity.latest_request_id =
typeof metadata?.request_id === "string" ? metadata.request_id : "unknown"; typeof metadata?.request_id === "string" ? metadata.request_id : "unknown";
existingActorActivity.latest_created_at_ms = eventAt; existingActorActivity.latest_created_at_ms = eventAt;
@@ -568,17 +743,30 @@ export async function GET(request: NextRequest) {
} }
}); });
const recentExternalEvents = recentEvents.filter((event) => const recentExternalEvents = recentEvents
event.action.startsWith("EXTERNAL_") && .filter(
!isInternalActor({ (event) =>
actorType: event.actorType, event.action.startsWith("EXTERNAL_") &&
actorId: event.actorId, !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( const recentInternalEvents = recentEvents
(event) => !event.action.startsWith("EXTERNAL_") .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()) const externalSurfaceSummary = Array.from(externalSourceSurfaceMap.entries())
.map(([surface, bucket]) => ({ .map(([surface, bucket]) => ({
@@ -629,6 +817,14 @@ export async function GET(request: NextRequest) {
}) })
.slice(0, 40); .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({ return NextResponse.json({
period_minutes: minutes, period_minutes: minutes,
total_events: totalRows, total_events: totalRows,
@@ -645,6 +841,7 @@ export async function GET(request: NextRequest) {
external_source_ip_summary: externalSourceIpSummary, external_source_ip_summary: externalSourceIpSummary,
external_user_agent_summary: externalUserAgentSummary, external_user_agent_summary: externalUserAgentSummary,
external_response_status_summary: externalResponseStatusSummary, external_response_status_summary: externalResponseStatusSummary,
external_actor_class_summary: externalActorClassSummaryRows,
external_actor_activities: externalActorActivityRows, external_actor_activities: externalActorActivityRows,
external_error_rows: externalErrorRowsSorted, external_error_rows: externalErrorRowsSorted,
recent_external_events: recentExternalEvents, 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"> <Link href="/traffic" className="inline-flex items-center gap-2 text-emerald-400 hover:text-emerald-300">
AI AI
</Link> </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> </div>
</div> </div>

View File

@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { headers } from "next/headers";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { isIP } from "node:net"; import { isIP } from "node:net";
@@ -77,7 +78,15 @@ function isInternalActor(input: { actorType: string | null | undefined; actorId:
return isInternalActorId(input.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; if (!token) return true;
return tokenHeader === token; return tokenHeader === token;
} }
@@ -91,9 +100,95 @@ function explainAction(action: string) {
return EVENT_LABELS[action] || action; 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 = { type ExternalActorActivity = {
actorId: string; actorId: string;
events: number; events: number;
actorClass: TrafficActorClass;
latestAction: string; latestAction: string;
latestSurface: string; latestSurface: string;
latestSourceIp: string; latestSourceIp: string;
@@ -268,6 +363,13 @@ async function getTrafficSummary(minutes: number) {
const recentEvents = latestEvents.map((event) => { const recentEvents = latestEvents.map((event) => {
const metadata = asRecordJson(event.metadata); const metadata = asRecordJson(event.metadata);
const actorClass = resolveActorClass(
event.action,
event.actorType,
event.actorId,
metadata,
typeof metadata?.surface === "string" ? metadata.surface : undefined
);
return { return {
...event, ...event,
surface: metadata?.surface, surface: metadata?.surface,
@@ -276,6 +378,7 @@ async function getTrafficSummary(minutes: number) {
response_summary: typeof metadata?.response_summary === "string" ? metadata.response_summary : "unknown", response_summary: typeof metadata?.response_summary === "string" ? metadata.response_summary : "unknown",
payload_summary: typeof metadata?.payload_summary === "string" ? metadata.payload_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", request_id: typeof metadata?.request_id === "string" ? metadata.request_id : "n/a",
actor_class: actorClass,
metadata, metadata,
}; };
}); });
@@ -315,6 +418,15 @@ async function getTrafficSummary(minutes: number) {
.filter((event) => event.action.includes("ERROR")) .filter((event) => event.action.includes("ERROR"))
.map((event) => event.action); .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 demandHealthLabel = demandSupply.openTaskCount > 0 ? "有可接需求" : "無可接需求";
const demandHealthTone = demandSupply.openTaskCount > 0 ? "text-emerald-300" : "text-amber-300"; 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 actorId = event.actorId || "agent:unknown";
const actorClass = (event as { actor_class?: TrafficActorClass }).actor_class || "other_external";
const isInternal = isInternalActor({ const isInternal = isInternalActor({
actorType: event.actorType, actorType: event.actorType,
actorId: event.actorId, actorId: event.actorId,
}); });
if (isInternal || event.actorType !== "AGENT") { const isTrackedExternal =
!isInternal &&
(actorClass === "a2a" || actorClass === "external_ai_agent" || actorClass === "likely_ai_agent");
if (!isTrackedExternal) {
continue; continue;
} }
@@ -357,6 +474,7 @@ async function getTrafficSummary(minutes: number) {
externalActorActivityMap.set(actorId, { externalActorActivityMap.set(actorId, {
actorId, actorId,
events: 1, events: 1,
actorClass,
latestAction: event.action, latestAction: event.action,
latestSurface: String(event.surface || "unknown"), latestSurface: String(event.surface || "unknown"),
latestSourceIp: normalizedIp, latestSourceIp: normalizedIp,
@@ -438,6 +556,7 @@ async function getTrafficSummary(minutes: number) {
recentInternalEvents: recentEvents.filter((event) => !event.action.startsWith("EXTERNAL_")), recentInternalEvents: recentEvents.filter((event) => !event.action.startsWith("EXTERNAL_")),
conversionSummary, conversionSummary,
conversionRates, conversionRates,
externalActorClassSummary,
externalActorActivities, externalActorActivities,
externalErrors, externalErrors,
demandSupply, demandSupply,
@@ -503,8 +622,11 @@ export default async function TrafficDashboard({
const resolved = await searchParams; const resolved = await searchParams;
const token = resolved?.token; const token = resolved?.token;
const minutes = Math.max(parseInt(resolved?.minutes || "1440", 10) || 5, 5); 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 ( return (
<div className="min-h-screen bg-gray-950 text-gray-100 p-8 font-sans"> <div className="min-h-screen bg-gray-950 text-gray-100 p-8 font-sans">
<div className="max-w-5xl mx-auto"> <div className="max-w-5xl mx-auto">
@@ -666,6 +788,22 @@ export default async function TrafficDashboard({
</div> </div>
<div className="grid gap-4 lg:grid-cols-2"> <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"> <div className="bg-gray-900 border border-gray-800 rounded-2xl p-6">
<h2 className="text-xl font-semibold mb-4"> Actor 20</h2> <h2 className="text-xl font-semibold mb-4"> Actor 20</h2>
<div className="space-y-2 max-h-80 overflow-auto"> <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"> <table className="w-full text-sm">
<thead> <thead>
<tr className="text-gray-400 border-b border-gray-700"> <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">Actor</th>
<th className="text-left py-2"></th> <th className="text-left py-2"></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> <tbody>
{summary.externalActorActivities.length === 0 ? ( {summary.externalActorActivities.length === 0 ? (
<tr> <tr>
<td colSpan={8} className="text-gray-500 py-3"> <td colSpan={9} className="text-gray-500 py-3">
AGENT AGENT AGENT AGENT
</td> </td>
</tr> </tr>
) : ( ) : (
summary.externalActorActivities.map((actor) => ( summary.externalActorActivities.map((actor) => (
<tr key={actor.actorId} className="border-b border-gray-800"> <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-gray-300">{actor.actorId}</td>
<td className="py-2 text-emerald-300">{actor.events}</td> <td className="py-2 text-emerald-300">{actor.events}</td>
<td className="py-2"> <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 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="font-mono text-emerald-300">{event.action}</div>
<div className="text-gray-400"> <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>
<div className="text-gray-500 text-xs mt-1"> <div className="text-gray-500 text-xs mt-1">
response={event.response_status ?? "n/a"} / summary={event.response_summary} 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; surface: string;
actorType: "SYSTEM" | "AGENT" | "USER"; actorType: "SYSTEM" | "AGENT" | "USER";
actorId: string; actorId: string;
actorClass?: "a2a" | "external_ai_agent" | "likely_ai_agent" | "other_external";
taskId?: string; taskId?: string;
message: string; message: string;
sourceIp?: string; sourceIp?: string;
@@ -58,6 +59,76 @@ const TELEGRAM_IP_FAMILY = Number.parseInt(
process.env.TELEGRAM_IP_FAMILY?.trim() || "4", process.env.TELEGRAM_IP_FAMILY?.trim() || "4",
10 10
) as 4 | 6; ) 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)); const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -245,17 +316,24 @@ function extractIpFromActor(actorType: TrafficAlertEvent["actorType"], actorId:
function resolveDisplaySourceIp(event: TrafficAlertEvent) { function resolveDisplaySourceIp(event: TrafficAlertEvent) {
const metadataIp = typeof event.metadata?.source_ip === "string" ? event.metadata.source_ip : undefined; 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) { function resolveDisplayUserAgent(event: TrafficAlertEvent) {
return ( return (
event.userAgent || 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) { function buildTelegramMessage(event: TrafficAlertEvent) {
const actorClass = resolveActorClass(event);
return ( return (
`<b>VibeWork 流量告警</b>` + `<b>VibeWork 流量告警</b>` +
`\n- 平台: <code>agent-bounty-protocol</code>` + `\n- 平台: <code>agent-bounty-protocol</code>` +
@@ -263,6 +341,7 @@ function buildTelegramMessage(event: TrafficAlertEvent) {
`\n- 行為: <code>${escapeHtml(event.action)}</code>` + `\n- 行為: <code>${escapeHtml(event.action)}</code>` +
`\n- 通道: <code>${escapeHtml(event.surface)}</code>` + `\n- 通道: <code>${escapeHtml(event.surface)}</code>` +
`\n- Actor: <code>${escapeHtml(`${event.actorType}/${event.actorId}`)}</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- Source IP: <code>${escapeHtml(resolveDisplaySourceIp(event) || "n/a")}</code>` +
`\n- User-Agent: <code>${escapeHtml(resolveDisplayUserAgent(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>` + `\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>) { async function sendViaHttps(url: string, body: Record<string, unknown>) {
return new Promise<{ messageId?: string }>((resolve, reject) => { return new Promise<{ messageId?: string }>((resolve, reject) => {
try { try {
@@ -389,7 +492,16 @@ function resolveEntityFromTrafficEvent(event: TrafficAlertEvent) {
} }
async function writeTrafficAuditEvent(event: TrafficAlertEvent) { async function writeTrafficAuditEvent(event: TrafficAlertEvent) {
const actorClass = resolveActorClass(event);
const { entityType, entityId } = resolveEntityFromTrafficEvent(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 { try {
await prisma.auditEvent.create({ await prisma.auditEvent.create({
@@ -404,8 +516,11 @@ async function writeTrafficAuditEvent(event: TrafficAlertEvent) {
reason: event.message, reason: event.message,
metadata: { metadata: {
...event.metadata, ...event.metadata,
actor_class: actorClass,
level: event.level, level: event.level,
surface: event.surface, surface: event.surface,
source_ip: sourceIp,
user_agent: userAgent,
source: "traffic-alert", source: "traffic-alert",
}, },
}, },
@@ -416,20 +531,23 @@ async function writeTrafficAuditEvent(event: TrafficAlertEvent) {
} }
export async function sendTrafficAlert(event: TrafficAlertEvent): Promise<void> { 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 = const eventSourceIp =
event.sourceIp ?? enrichedEvent.sourceIp ??
(typeof event.metadata?.source_ip === "string" ? event.metadata.source_ip : undefined); (typeof enrichedEvent.metadata?.source_ip === "string" ? enrichedEvent.metadata.source_ip : undefined);
const eventUserAgent = const eventUserAgent =
event.userAgent ?? enrichedEvent.userAgent ??
(typeof event.metadata?.user_agent === "string" ? event.metadata.user_agent : undefined); (typeof enrichedEvent.metadata?.user_agent === "string" ? enrichedEvent.metadata.user_agent : undefined);
const payload = { const payload = {
platform: "agent-bounty-protocol", platform: "agent-bounty-protocol",
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
actor_class: actorClass,
source_ip: eventSourceIp, source_ip: eventSourceIp,
user_agent: eventUserAgent, user_agent: eventUserAgent,
...event, ...enrichedEvent,
}; };
const resolvedTelegramChatId = await resolveTelegramChatId(); const resolvedTelegramChatId = await resolveTelegramChatId();
@@ -461,7 +579,8 @@ export async function sendTrafficAlert(event: TrafficAlertEvent): Promise<void>
}, },
}, },
TELEGRAM_BOT_TOKEN && TELEGRAM_BOT_TOKEN &&
resolvedTelegramChatId && { resolvedTelegramChatId &&
shouldNotifyViaTelegram(event) && {
kind: "telegram", kind: "telegram",
url: `https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage`, url: `https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage`,
init: { init: {

View File

@@ -17,6 +17,11 @@ type FunnelSummary = {
externalSubmittingActors: number; externalSubmittingActors: number;
externalOnlyOpenActors: number; externalOnlyOpenActors: number;
topOpenOnlyActors: Array<{ actorId: string; opens: number }>; topOpenOnlyActors: Array<{ actorId: string; opens: number }>;
judgeFailureReasons: Array<{
reason: string;
count: number;
examples: string[];
}>;
payoutCaptured: number; payoutCaptured: number;
payoutReleased: number; payoutReleased: number;
periodMinutes: number; periodMinutes: number;
@@ -109,7 +114,7 @@ async function fetchFunnelSummary(minutes: number): Promise<FunnelSummary> {
createdAt: { gte: since }, createdAt: { gte: since },
action: "JUDGE_COMPLETE", action: "JUDGE_COMPLETE",
}, },
select: { metadata: true }, select: { metadata: true, entityId: true },
}), }),
prisma.task.findMany({ prisma.task.findMany({
where: { where: {
@@ -177,15 +182,58 @@ async function fetchFunnelSummary(minutes: number): Promise<FunnelSummary> {
const mcpAuthMissingEvents = actionSummary["EXTERNAL_MCP_AUTH_MISSING"] || 0; const mcpAuthMissingEvents = actionSummary["EXTERNAL_MCP_AUTH_MISSING"] || 0;
const mcpAuthForbiddenEvents = actionSummary["EXTERNAL_MCP_AUTH_FORBIDDEN"] || 0; const mcpAuthForbiddenEvents = actionSummary["EXTERNAL_MCP_AUTH_FORBIDDEN"] || 0;
const judgePassEvents = judgeRows.filter((row) => { const judgePassRows = judgeRows.filter((row) => {
const metadata = asRecordJson(row.metadata); const metadata = asRecordJson(row.metadata);
return normalizedJudgeResult(metadata?.overall_result) === "pass"; return normalizedJudgeResult(metadata?.overall_result) === "pass";
}).length; });
const judgeFailRows = judgeRows.filter((row) => {
const judgeFailEvents = judgeRows.filter((row) => {
const metadata = asRecordJson(row.metadata); const metadata = asRecordJson(row.metadata);
return normalizedJudgeResult(metadata?.overall_result) === "fail"; 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< const actorMap = new Map<
string, string,
@@ -243,6 +291,7 @@ async function fetchFunnelSummary(minutes: number): Promise<FunnelSummary> {
externalSubmittingActors, externalSubmittingActors,
externalOnlyOpenActors, externalOnlyOpenActors,
topOpenOnlyActors, topOpenOnlyActors,
judgeFailureReasons,
payoutCaptured, payoutCaptured,
payoutReleased, payoutReleased,
periodMinutes: minutes, periodMinutes: minutes,
@@ -266,6 +315,7 @@ function buildAlertMessage(rule: string, summary: FunnelSummary) {
externalSubmittingActors, externalSubmittingActors,
externalOnlyOpenActors, externalOnlyOpenActors,
topOpenOnlyActors, topOpenOnlyActors,
judgeFailureReasons,
} = summary; } = summary;
const authBarrierEvents = mcpAuthMissingEvents + mcpAuthForbiddenEvents; const authBarrierEvents = mcpAuthMissingEvents + mcpAuthForbiddenEvents;
@@ -290,7 +340,21 @@ function buildAlertMessage(rule: string, summary: FunnelSummary) {
case "EXTERNAL_FUNNEL_SUBMIT_STALL": case "EXTERNAL_FUNNEL_SUBMIT_STALL":
return `外部已有 ${claimEvents} 筆接案,但近期 ${periodMinutes} 分鐘無任何提交EXTERNAL_SUBMIT_SOLUTION_SUCCESS = ${submitEvents})。請先加速回傳格式與驗收測試規格。`; return `外部已有 ${claimEvents} 筆接案,但近期 ${periodMinutes} 分鐘無任何提交EXTERNAL_SUBMIT_SOLUTION_SUCCESS = ${submitEvents})。請先加速回傳格式與驗收測試規格。`;
case "EXTERNAL_FUNNEL_PASS_STALL": 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": case "EXTERNAL_FUNNEL_PAYOUT_STALL":
return `有 PASS 但未收款payout CAPTURE 成功 = ${payoutCaptured}。請確認支付授權、Stripe key 與 capture 任務是否正常。`; return `有 PASS 但未收款payout CAPTURE 成功 = ${payoutCaptured}。請確認支付授權、Stripe key 與 capture 任務是否正常。`;
case "EXTERNAL_FUNNEL_OPEN_COLD_STANDBY": case "EXTERNAL_FUNNEL_OPEN_COLD_STANDBY":
@@ -384,8 +448,12 @@ export async function evaluateExternalFunnelHealth(input: MonitorInput): Promise
surface: input.surface, surface: input.surface,
actorType: "SYSTEM", actorType: "SYSTEM",
actorId: "traffic-monitor", actorId: "traffic-monitor",
sourceIp: "system",
userAgent: "traffic-monitor",
message: rule.message, message: rule.message,
metadata: { metadata: {
source_ip: "system",
user_agent: "traffic-monitor",
discovery_events: summary.discoveryEvents, discovery_events: summary.discoveryEvents,
claim_events: summary.claimEvents, claim_events: summary.claimEvents,
submit_events: summary.submitEvents, submit_events: summary.submitEvents,
@@ -399,6 +467,7 @@ export async function evaluateExternalFunnelHealth(input: MonitorInput): Promise
external_only_open_actors: summary.externalOnlyOpenActors, external_only_open_actors: summary.externalOnlyOpenActors,
mcp_auth_missing_events: summary.mcpAuthMissingEvents, mcp_auth_missing_events: summary.mcpAuthMissingEvents,
mcp_auth_forbidden_events: summary.mcpAuthForbiddenEvents, mcp_auth_forbidden_events: summary.mcpAuthForbiddenEvents,
judge_failure_reasons: summary.judgeFailureReasons,
payout_captured: summary.payoutCaptured, payout_captured: summary.payoutCaptured,
payout_released: summary.payoutReleased, payout_released: summary.payoutReleased,
period_minutes: summary.periodMinutes, period_minutes: summary.periodMinutes,

View File

@@ -1,12 +1,31 @@
import { NextResponse } from 'next/server'; import { NextResponse } from "next/server";
import type { NextRequest } from 'next/server'; import type { NextRequest } from "next/server";
import {
adminUnauthorizedResponse,
attachAdminHeaders,
isAdminRequestAuthorized,
} from "@/lib/admin-auth";
export function middleware(request: NextRequest) { export function middleware(request: NextRequest) {
const url = request.nextUrl; 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= // Check if there is a referral parameter ?ref=
const ref = url.searchParams.get('ref'); const ref = url.searchParams.get('ref');
if (ref) { if (ref) {
const response = NextResponse.redirect(url.pathname); const response = NextResponse.redirect(url.pathname);
// Set cookie for 30 days // Set cookie for 30 days
@@ -25,13 +44,6 @@ export function middleware(request: NextRequest) {
export const config = { export const config = {
matcher: [ matcher: [
/* '/((?!_next/static|_next/image|favicon.ico).*)',
* 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).*)',
], ],
}; };

View File

@@ -54,6 +54,8 @@ services:
- ALLOW_MCP_CLAIM_WITHOUT_STRIPE=${ALLOW_MCP_CLAIM_WITHOUT_STRIPE:-false} - ALLOW_MCP_CLAIM_WITHOUT_STRIPE=${ALLOW_MCP_CLAIM_WITHOUT_STRIPE:-false}
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-} - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-}
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-} - TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-}
- TELEGRAM_NOTIFY_EXTERNAL_ONLY=${TELEGRAM_NOTIFY_EXTERNAL_ONLY:-true}
- TELEGRAM_NOTIFY_SYSTEM_WARN_INTERNAL_SUPPRESS=${TELEGRAM_NOTIFY_SYSTEM_WARN_INTERNAL_SUPPRESS:-true}
- TRAFFIC_MONITOR_TOKEN=${TRAFFIC_MONITOR_TOKEN:-} - TRAFFIC_MONITOR_TOKEN=${TRAFFIC_MONITOR_TOKEN:-}
- VIBEWORK_TRAFFIC_WEBHOOK_URL=${VIBEWORK_TRAFFIC_WEBHOOK_URL:-} - VIBEWORK_TRAFFIC_WEBHOOK_URL=${VIBEWORK_TRAFFIC_WEBHOOK_URL:-}
depends_on: depends_on:

View File

@@ -1,10 +1,22 @@
from .client import VibeWorkAgentSDK, VibeWorkApiError from .client import VibeWorkAgentSDK, VibeWorkApiError
from .models import ( from .models import (
AgentCard, AgentCard,
ClaimTaskResponse,
ClaimTaskRequest, ClaimTaskRequest,
ClaimTaskResponse,
SubmitSolutionRequest, SubmitSolutionRequest,
SubmitSolutionResponse, SubmitSolutionResponse,
ListOpenTasksMcpResponse,
CreateSubTaskRequest,
CreateSubTaskResponse,
RequestPeerReviewRequest,
RequestPeerReviewResponse,
BroadcastHelpSignalRequest,
BroadcastHelpSignalResponse,
QueryAgentMemoryRequest,
QueryAgentMemoryResponse,
RentApiResourceRequest,
RentApiResourceResponse,
A2AResourceType,
TaskBounty, TaskBounty,
) )
@@ -17,4 +29,16 @@ __all__ = [
"SubmitSolutionRequest", "SubmitSolutionRequest",
"SubmitSolutionResponse", "SubmitSolutionResponse",
"TaskBounty", "TaskBounty",
"ListOpenTasksMcpResponse",
"CreateSubTaskRequest",
"CreateSubTaskResponse",
"RequestPeerReviewRequest",
"RequestPeerReviewResponse",
"BroadcastHelpSignalRequest",
"BroadcastHelpSignalResponse",
"QueryAgentMemoryRequest",
"QueryAgentMemoryResponse",
"RentApiResourceRequest",
"RentApiResourceResponse",
"A2AResourceType",
] ]

View File

@@ -12,6 +12,17 @@ from .models import (
ClaimTaskResponse, ClaimTaskResponse,
SubmitSolutionRequest, SubmitSolutionRequest,
SubmitSolutionResponse, SubmitSolutionResponse,
ListOpenTasksMcpResponse,
CreateSubTaskRequest,
CreateSubTaskResponse,
RequestPeerReviewRequest,
RequestPeerReviewResponse,
BroadcastHelpSignalRequest,
BroadcastHelpSignalResponse,
QueryAgentMemoryRequest,
QueryAgentMemoryResponse,
RentApiResourceRequest,
RentApiResourceResponse,
TaskBounty, TaskBounty,
) )
@@ -81,6 +92,49 @@ class TasksModule:
response = self.client._request("post", "/api/mcp/submit_solution", payload=payload.model_dump()) response = self.client._request("post", "/api/mcp/submit_solution", payload=payload.model_dump())
return SubmitSolutionResponse.model_validate(response) return SubmitSolutionResponse.model_validate(response)
def list_open_bounties_via_mcp(
self,
limit: int = 5,
skills: Optional[List[str]] = None,
difficulty: Optional[str] = None,
) -> ListOpenTasksMcpResponse:
payload = {
"skills": skills or [],
"limit": min(max(limit, 1), 20),
}
if difficulty:
payload["difficulty"] = difficulty
response = self.client._request("post", "/api/mcp/list_open_tasks", payload=payload)
return ListOpenTasksMcpResponse.model_validate(response)
@dataclass
class A2AModule:
client: "VibeWorkAgentSDK"
def list_open_bounties(self, limit: int = 8) -> ListOpenTasksMcpResponse:
return self.client.tasks.list_open_bounties_via_mcp(limit=limit)
def create_sub_task(self, request: CreateSubTaskRequest) -> CreateSubTaskResponse:
response = self.client._request("post", "/api/mcp/create_sub_task", payload=request.model_dump())
return CreateSubTaskResponse.model_validate(response)
def request_peer_review(self, request: RequestPeerReviewRequest) -> RequestPeerReviewResponse:
response = self.client._request("post", "/api/mcp/request_peer_review", payload=request.model_dump())
return RequestPeerReviewResponse.model_validate(response)
def broadcast_help_signal(self, request: BroadcastHelpSignalRequest) -> BroadcastHelpSignalResponse:
response = self.client._request("post", "/api/mcp/broadcast_help_signal", payload=request.model_dump())
return BroadcastHelpSignalResponse.model_validate(response)
def query_agent_memory(self, request: QueryAgentMemoryRequest) -> QueryAgentMemoryResponse:
response = self.client._request("post", "/api/mcp/query_agent_memory", payload=request.model_dump())
return QueryAgentMemoryResponse.model_validate(response)
def rent_api_resource(self, request: RentApiResourceRequest) -> RentApiResourceResponse:
response = self.client._request("post", "/api/mcp/rent_api_resource", payload=request.model_dump())
return RentApiResourceResponse.model_validate(response)
class VibeWorkAgentSDK: class VibeWorkAgentSDK:
def __init__(self, base_url: str = "https://agent.wooo.work", api_key: str | None = None): def __init__(self, base_url: str = "https://agent.wooo.work", api_key: str | None = None):
@@ -93,6 +147,7 @@ class VibeWorkAgentSDK:
self.identity = IdentityModule(self) self.identity = IdentityModule(self)
self.tasks = TasksModule(self) self.tasks = TasksModule(self)
self.a2a = A2AModule(self)
def _request( def _request(
self, self,

View File

@@ -64,3 +64,95 @@ class SubmitSolutionResponse(BaseModel):
submission_id: str submission_id: str
status: Literal["VERIFYING"] status: Literal["VERIFYING"]
estimated_judge_complete_at: Optional[str] = None estimated_judge_complete_at: Optional[str] = None
ValidationMode = Literal["VITEST_UNIT", "PLAYWRIGHT_E2E", "AST_PARSING", "VISUAL_REGRESSION"]
A2AResourceType = Literal["GPT_4O", "CLAUDE_3_5_SONNET", "EMBEDDINGS"]
class AcceptanceRule(BaseModel):
assertion: str
expected: object
description: Optional[str] = None
class AcceptanceCriteria(BaseModel):
validation_mode: ValidationMode
test_file_content: str
rules: Optional[List[AcceptanceRule]] = None
class ListOpenTasksMcpResponse(BaseModel):
tasks: List[TaskBounty] = Field(default_factory=list)
total_open: int
stockout_warning: bool
class CreateSubTaskRequest(BaseModel):
parent_task_id: str
claim_token: str
title: str
description: str
reward_amount: int
acceptance_criteria: AcceptanceCriteria
class CreateSubTaskResponse(BaseModel):
sub_task_id: str
status: Literal["DRAFT", "OPEN"]
class RequestPeerReviewRequest(BaseModel):
parent_task_id: str
claim_token: str
code_snippet: str
review_instructions: str
class RequestPeerReviewResponse(BaseModel):
review_task_id: str
status: Literal["OPEN"]
cost: int
message: str
class BroadcastHelpSignalRequest(BaseModel):
parent_task_id: str
claim_token: str
error_message: str
contextual_code: Optional[str] = None
class BroadcastHelpSignalResponse(BaseModel):
sos_task_id: str
status: Literal["OPEN"]
message: str
class QueryAgentMemoryRequest(BaseModel):
query: str
error_code: Optional[str] = None
class QueryAgentMemoryResult(BaseModel):
task_title: str
deliverables: object
similarity_score: Optional[float] = None
class QueryAgentMemoryResponse(BaseModel):
results: List[QueryAgentMemoryResult]
class RentApiResourceRequest(BaseModel):
agent_id: str
resource_type: A2AResourceType
duration_minutes: int
class RentApiResourceResponse(BaseModel):
status: Literal["GRANTED", "INSUFFICIENT_FUNDS"]
proxy_url: Optional[str] = None
proxy_token: Optional[str] = None
cost_deducted: Optional[int] = None
message: str

View File

@@ -1,4 +1,5 @@
import axios, { AxiosInstance } from 'axios'; import axios, { AxiosInstance } from 'axios';
import crypto from 'node:crypto';
import { AgentSdkOptions } from './types'; import { AgentSdkOptions } from './types';
export class VibeWorkClient { export class VibeWorkClient {
@@ -6,13 +7,34 @@ export class VibeWorkClient {
constructor(options: AgentSdkOptions) { constructor(options: AgentSdkOptions) {
const baseUrl = options.baseUrl || 'https://agent.wooo.work'; const baseUrl = options.baseUrl || 'https://agent.wooo.work';
this.http = axios.create({ this.http = axios.create({
baseURL: baseUrl, baseURL: baseUrl,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(options.apiKey ? { 'Authorization': `Bearer ${options.apiKey}` } : {}) ...(options.apiKey ? { 'Authorization': `Bearer ${options.apiKey}` } : {}),
...(options.agentId ? { 'x-agent-id': options.agentId } : {}),
...(options.agentName ? { 'x-agent-name': options.agentName } : {}),
} }
}); });
this.http.interceptors.request.use((config) => {
const headers = {
...(config.headers as Record<string, string | number | boolean | undefined> | undefined),
};
if (!headers['x-request-id']) {
headers['x-request-id'] = crypto.randomUUID();
}
if (options.agentId && !headers['x-agent-id']) {
headers['x-agent-id'] = options.agentId;
}
if (options.agentName && !headers['x-agent-name']) {
headers['x-agent-name'] = options.agentName;
}
config.headers = headers as any;
return config;
});
} }
} }

View File

@@ -2,6 +2,7 @@ import { VibeWorkClient } from './client';
import { AgentSdkOptions } from './types'; import { AgentSdkOptions } from './types';
import { TasksModule } from './modules/tasks'; import { TasksModule } from './modules/tasks';
import { IdentityModule } from './modules/identity'; import { IdentityModule } from './modules/identity';
import { A2AModule } from './modules/a2a';
export * from './types'; export * from './types';
@@ -9,10 +10,12 @@ export class VibeWorkAgentSDK {
public client: VibeWorkClient; public client: VibeWorkClient;
public tasks: TasksModule; public tasks: TasksModule;
public identity: IdentityModule; public identity: IdentityModule;
public a2a: A2AModule;
constructor(options: AgentSdkOptions = {}) { constructor(options: AgentSdkOptions = {}) {
this.client = new VibeWorkClient(options); this.client = new VibeWorkClient(options);
this.tasks = new TasksModule(this.client); this.tasks = new TasksModule(this.client);
this.identity = new IdentityModule(this.client); this.identity = new IdentityModule(this.client);
this.a2a = new A2AModule(this.client);
} }
} }

View File

@@ -0,0 +1,55 @@
import { VibeWorkClient } from '../client';
import {
BroadcastHelpSignalRequest,
BroadcastHelpSignalResponse,
CreateSubTaskRequest,
CreateSubTaskResponse,
ListOpenTasksMcpResponse,
QueryAgentMemoryRequest,
QueryAgentMemoryResponse,
RentApiResourceRequest,
RentApiResourceResponse,
RequestPeerReviewRequest,
RequestPeerReviewResponse,
} from '../types';
export class A2AModule {
private client: VibeWorkClient;
constructor(client: VibeWorkClient) {
this.client = client;
}
async listOpenBounties(limit = 10): Promise<ListOpenTasksMcpResponse> {
const response = await this.client.http.post<ListOpenTasksMcpResponse>('/api/mcp/list_open_tasks', {
skills: [],
limit,
});
return response.data;
}
async createSubTask(payload: CreateSubTaskRequest): Promise<CreateSubTaskResponse> {
const response = await this.client.http.post<CreateSubTaskResponse>('/api/mcp/create_sub_task', payload);
return response.data;
}
async requestPeerReview(payload: RequestPeerReviewRequest): Promise<RequestPeerReviewResponse> {
const response = await this.client.http.post<RequestPeerReviewResponse>('/api/mcp/request_peer_review', payload);
return response.data;
}
async broadcastHelpSignal(payload: BroadcastHelpSignalRequest): Promise<BroadcastHelpSignalResponse> {
const response = await this.client.http.post<BroadcastHelpSignalResponse>('/api/mcp/broadcast_help_signal', payload);
return response.data;
}
async queryAgentMemory(payload: QueryAgentMemoryRequest): Promise<QueryAgentMemoryResponse> {
const response = await this.client.http.post<QueryAgentMemoryResponse>('/api/mcp/query_agent_memory', payload);
return response.data;
}
async rentApiResource(payload: RentApiResourceRequest): Promise<RentApiResourceResponse> {
const response = await this.client.http.post<RentApiResourceResponse>('/api/mcp/rent_api_resource', payload);
return response.data;
}
}

View File

@@ -1,5 +1,5 @@
import { VibeWorkClient } from '../client'; import { VibeWorkClient } from '../client';
import { ClaimTaskRequest, ClaimTaskResponse, SubmitSolutionRequest, SubmitSolutionResponse, TaskBounty } from '../types'; import { ClaimTaskRequest, ClaimTaskResponse, ListOpenTasksMcpResponse, SubmitSolutionRequest, SubmitSolutionResponse, TaskBounty } from '../types';
export class TasksModule { export class TasksModule {
private client: VibeWorkClient; private client: VibeWorkClient;
@@ -16,6 +16,17 @@ export class TasksModule {
return response.data.tasks.slice(0, limit); return response.data.tasks.slice(0, limit);
} }
/**
* List open tasks through MCP endpoint, used by external AI runtime integrations.
*/
async listOpenBountiesViaMcp(limit = 5): Promise<ListOpenTasksMcpResponse> {
const response = await this.client.http.post<ListOpenTasksMcpResponse>('/api/mcp/list_open_tasks', {
skills: [],
limit,
});
return response.data;
}
/** /**
* Claim a bounty task * Claim a bounty task
*/ */

View File

@@ -1,6 +1,8 @@
export interface AgentSdkOptions { export interface AgentSdkOptions {
apiKey?: string; apiKey?: string;
baseUrl?: string; baseUrl?: string;
agentId?: string;
agentName?: string;
} }
export interface PagedResponse<T> { export interface PagedResponse<T> {
@@ -93,3 +95,95 @@ export interface AgentProfile {
wallet_address?: string; wallet_address?: string;
reputation_score?: number; reputation_score?: number;
} }
export type ValidationMode = "VITEST_UNIT" | "PLAYWRIGHT_E2E" | "AST_PARSING" | "VISUAL_REGRESSION";
export interface AcceptanceRule {
assertion: string;
expected: unknown;
description?: string;
}
export interface AcceptanceCriteria {
validation_mode: ValidationMode;
test_file_content: string;
rules?: AcceptanceRule[];
}
export interface ListOpenTasksMcpResponse {
tasks: TaskBounty[];
total_open: number;
stockout_warning: boolean;
}
export interface CreateSubTaskRequest {
parent_task_id: string;
claim_token: string;
title: string;
description: string;
reward_amount: number;
acceptance_criteria: AcceptanceCriteria;
}
export interface CreateSubTaskResponse {
sub_task_id: string;
status: "DRAFT" | "OPEN";
}
export interface RequestPeerReviewRequest {
parent_task_id: string;
claim_token: string;
code_snippet: string;
review_instructions: string;
}
export interface RequestPeerReviewResponse {
review_task_id: string;
status: "OPEN";
cost: number;
message: string;
}
export interface BroadcastHelpSignalRequest {
parent_task_id: string;
claim_token: string;
error_message: string;
contextual_code?: string;
}
export interface BroadcastHelpSignalResponse {
sos_task_id: string;
status: "OPEN";
message: string;
}
export interface QueryAgentMemoryRequest {
query: string;
error_code?: string;
}
export interface QueryAgentMemoryResult {
task_title: string;
deliverables: unknown;
similarity_score?: number;
}
export interface QueryAgentMemoryResponse {
results: QueryAgentMemoryResult[];
}
export type A2AResourceType = "GPT_4O" | "CLAUDE_3_5_SONNET" | "EMBEDDINGS";
export interface RentApiResourceRequest {
agent_id: string;
resource_type: A2AResourceType;
duration_minutes: number;
}
export interface RentApiResourceResponse {
status: "GRANTED" | "INSUFFICIENT_FUNDS";
proxy_url?: string;
proxy_token?: string;
cost_deducted?: number;
message: string;
}

Binary file not shown.

9
scripts/clear_audit.ts Normal file
View File

@@ -0,0 +1,9 @@
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
async function main() {
await prisma.auditEvent.deleteMany({
where: { action: "A2A_NETWORK_BROADCAST" }
});
console.log("Cleared A2A audit logs.");
}
main().finally(() => prisma.$disconnect());

View File

@@ -0,0 +1,42 @@
#!/usr/bin/env bash
set -euo pipefail
JUMP_HOST="${JUMP_HOST:-wooo@192.168.0.110}"
TARGET_HOST="${TARGET_HOST:-ollama@192.168.0.188}"
REPO_DIR="${REPO_DIR:-/home/ollama/vibework-git}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="${REPO_ROOT:-$SCRIPT_DIR/..}"
ENV_FILE="${1:-$SCRIPT_DIR/ecosystem-hunter.env}"
SERVICE_FILE="$REPO_ROOT/scripts/systemd/agent-bounty-ecosystem-hunter.service"
LOCAL_HOME="$(cd "." && pwd)"
if [[ -z "$SERVICE_FILE" || ! -f "$SERVICE_FILE" ]]; then
echo "service file missing: $SERVICE_FILE"
exit 1
fi
if [[ ! -f "$ENV_FILE" ]]; then
echo "env missing: $ENV_FILE"
echo "Please copy from scripts/ecosystem-hunter.env.example and edit values first."
exit 1
fi
ssh -J "$JUMP_HOST" "$TARGET_HOST" \
"mkdir -p $REPO_DIR/scripts/systemd $REPO_DIR/scripts ~/.config/systemd/user ~/.local/logs/agent-bounty-ecosystem-hunter"
echo "[deploy] prepare local sync files..."
RSYNC_CMD=(rsync -avz -e "ssh -J $JUMP_HOST")
"${RSYNC_CMD[@]}" "$ENV_FILE" "$TARGET_HOST:$REPO_DIR/scripts/ecosystem-hunter.env"
"${RSYNC_CMD[@]}" "$LOCAL_HOME/scripts/systemd/agent-bounty-ecosystem-hunter.service" "$TARGET_HOST:$REPO_DIR/scripts/systemd/agent-bounty-ecosystem-hunter.service"
"${RSYNC_CMD[@]}" "$LOCAL_HOME/scripts/run_ecosystem_hunter.sh" "$TARGET_HOST:$REPO_DIR/scripts/run_ecosystem_hunter.sh"
"${RSYNC_CMD[@]}" "$LOCAL_HOME/scripts/nostr_agent_client.py" "$TARGET_HOST:$REPO_DIR/scripts/nostr_agent_client.py"
"${RSYNC_CMD[@]}" "$LOCAL_HOME/scripts/ecosystem-hunter-endpoints.txt" "$TARGET_HOST:$REPO_DIR/scripts/ecosystem-hunter-endpoints.txt"
ssh -J "$JUMP_HOST" "$TARGET_HOST" "\
mkdir -p $REPO_DIR/.local/logs/agent-bounty-ecosystem-hunter && \
chmod 600 $REPO_DIR/scripts/ecosystem-hunter.env && \
chmod +x $REPO_DIR/scripts/run_ecosystem_hunter.sh && \
cp $REPO_DIR/scripts/systemd/agent-bounty-ecosystem-hunter.service ~/.config/systemd/user/agent-bounty-ecosystem-hunter.service && \
systemctl --user daemon-reload && \
systemctl --user enable --now agent-bounty-ecosystem-hunter.service && \
systemctl --user status --no-pager agent-bounty-ecosystem-hunter.service"

View File

@@ -0,0 +1 @@
https://agent.wooo.work

View File

@@ -0,0 +1,24 @@
# Copy to scripts/ecosystem-hunter.env and edit values
MCP_API_KEY=vw_beta_promo_2026
MCP_API_BASE=https://agent.wooo.work
MCP_AGENT_ID=vibe-hunter-prod
DEVELOPER_WALLET=acct_ecosystem_hunter
AUTO_CLAIM=false
AUTO_SUBMIT=false
AUTO_SUBMIT_PR_URL=https://github.com/vibework/a2a-ecosystem-hunter/pull/1
RUN_DAEMON=true
SCAN_INTERVAL_SECONDS=300
RECONNECTION_BACKOFF_SECONDS=20
NOSTR_RELAY_URL=wss://relay.damus.io
NOSTR_TAG=VibeWork_Bounty
NOSTR_LIMIT=40
EXTERNAL_MCP_ENDPOINTS=https://agent.wooo.work
KNOWN_MCP_ENDPOINTS=https://agent.wooo.work
# 可選:外部 MCP 候選清單檔,會額外補足種子端點(支援一行一個)
MCP_ENDPOINTS_FILE=/home/ollama/vibework-git/scripts/ecosystem-hunter-endpoints.txt
MCP_TIMEOUT_SECONDS=12
ECOSYSTEM_REPORT_PATH=/home/ollama/vibework-git/artifacts/ecosystem_hunter_report.jsonl
REPORT_LOG_DIR=/home/ollama/vibework-git/.local/logs/agent-bounty-ecosystem-hunter
STDOUT_LOG=/home/ollama/vibework-git/.local/logs/agent-bounty-ecosystem-hunter/ecosystem-hunter.stdout.log
STDERR_LOG=/home/ollama/vibework-git/.local/logs/agent-bounty-ecosystem-hunter/ecosystem-hunter.stderr.log
PYTHON_BIN=/usr/bin/python3

View File

@@ -0,0 +1,23 @@
# Copy to scripts/ecosystem-hunter.env and edit values
MCP_API_KEY=vw_beta_promo_2026
MCP_API_BASE=https://agent.wooo.work
MCP_AGENT_ID=vibe-hunter-prod
DEVELOPER_WALLET=acct_ecosystem_hunter
AUTO_CLAIM=false
AUTO_SUBMIT=false
AUTO_SUBMIT_PR_URL=https://github.com/vibework/a2a-ecosystem-hunter/pull/1
RUN_DAEMON=true
SCAN_INTERVAL_SECONDS=300
RECONNECTION_BACKOFF_SECONDS=20
NOSTR_RELAY_URL=wss://relay.damus.io
NOSTR_TAG=VibeWork_Bounty
NOSTR_LIMIT=40
EXTERNAL_MCP_ENDPOINTS=https://agent.wooo.work
KNOWN_MCP_ENDPOINTS=https://agent.wooo.work
# 可選:外部 MCP 候選清單檔,會額外補足種子端點(支援一行一個)
MCP_ENDPOINTS_FILE=/home/ollama/vibework-git/scripts/ecosystem-hunter-endpoints.txt
MCP_TIMEOUT_SECONDS=12
ECOSYSTEM_REPORT_PATH=/home/ollama/vibework-git/artifacts/ecosystem_hunter_report.jsonl
REPORT_LOG_DIR=/home/ollama/vibework-git/.local/logs/agent-bounty-ecosystem-hunter
STDOUT_LOG=/home/ollama/vibework-git/.local/logs/agent-bounty-ecosystem-hunter/ecosystem-hunter.stdout.log
STDERR_LOG=/home/ollama/vibework-git/.local/logs/agent-bounty-ecosystem-hunter/ecosystem-hunter.stderr.log

View File

@@ -0,0 +1,67 @@
import { v4 as uuidv4 } from "uuid";
const API_BASE = "http://192.168.0.110:3000/api";
const MCP_API_BASE = "http://192.168.0.110:3000/api/mcp";
const MCP_API_KEY = "vw_beta_promo_2026";
async function callMcpTool(tool: string, payload: any) {
const res = await fetch(`${MCP_API_BASE}/${tool}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${MCP_API_KEY}`
},
body: JSON.stringify(payload)
});
const data = await res.json();
if (!res.ok) {
throw new Error(`Tool ${tool} failed. ` + JSON.stringify(data));
}
return data;
}
async function main() {
console.log("🚀 [Mock Dispatcher] Starting test flow...");
// 1. Find Open Bounties
const openTasksRes = await fetch(`${API_BASE}/open-tasks`);
const openTasks = await openTasksRes.json();
if (!openTasks || !openTasks.tasks || openTasks.tasks.length === 0) {
console.log("✅ No open tasks. We need to create a test task first.");
return;
}
const task = openTasks.tasks[0];
console.log(`🎯 Found Task: "${task.title}"`);
// 2. Mock Agent
const agentId = `mock-agent-${Math.floor(Math.random() * 1000)}`;
console.log(`🤖 Agent ID: ${agentId}`);
// 3. Claim task
const claimRes = await callMcpTool("claim_task", {
task_id: task.task_id,
agent_id: agentId,
developer_wallet: "acct_1MockStripeOutboundAgent"
});
console.log("✅ Claimed successfully. Token:", claimRes.claim_token);
// 4. Mock Solution
console.log("🧠 Mocking solution generation...");
const solutionObj = {
"solution.ts": "// Fake solution"
};
// 5. Submit solution
const solutionRes = await callMcpTool("submit_solution", {
task_id: task.task_id,
claim_token: claimRes.claim_token,
deliverables: solutionObj,
github_pr_url: "https://github.com/agent-bounty/external-agents/pull/999"
});
console.log(`🎉 Success! Solution submitted. Submission ID: ${solutionRes.submission_id}`);
}
main().catch(console.error);

View File

@@ -0,0 +1,40 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="${1:-https://agent.wooo.work}"
MINUTES="${2:-60}"
TRAFFIC_TOKEN="${TRAFFIC_MONITOR_TOKEN:-}"
API_URL="${BASE_URL%/}/api/traffic?minutes=${MINUTES}"
curl_cmd=(curl -sS)
export BASE_URL MINUTES
if [ -n "$TRAFFIC_TOKEN" ]; then
curl_cmd+=("-H" "x-traffic-token: $TRAFFIC_TOKEN")
fi
PYTHON_INPUT="$(${curl_cmd[@]} "$API_URL")"
export PYTHON_INPUT
python3 - <<'PY'
import json
import os
import sys
raw = os.environ.get("PYTHON_INPUT", "")
try:
payload = json.loads(raw)
except json.JSONDecodeError:
print("[A2A External] 無法解析 /api/traffic 回應")
print(raw[:500])
sys.exit(1)
print(f"[A2A External] base={os.environ.get('BASE_URL')} minutes={os.environ.get('MINUTES')}")
print(f"total_events={payload.get('total_events')} channel={payload.get('channel_summary', {}).get('external')}")
print(f"claim_success={payload.get('conversion_rates', {}).get('claim_rate')} submit_success={payload.get('conversion_rates', {}).get('submit_rate')}")
print("external_funnel:", payload.get("external_funnel"))
print("top_actors:", payload.get("external_actor_summary"))
print("recent_events:")
for row in (payload.get("recent_external_events") or [])[:8]:
print(f" - {row.get('action')} | {row.get('actorId')} | {row.get('surface')} | {row.get('metadata', {}).get('response_status')} | {row.get('reason')}")
PY

View File

@@ -0,0 +1,610 @@
#!/usr/bin/env python3
import asyncio
import json
import os
import random
import time
import urllib.error
import urllib.request
from pathlib import Path
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Set
try:
import websockets
except Exception:
websockets = None
RELAY_URL = os.getenv("NOSTR_RELAY_URL", "wss://relay.damus.io")
NOSTR_TAG = os.getenv("NOSTR_TAG", "VibeWork_Bounty")
NOSTR_TAG_FALLBACK = "A2A"
NOSTR_LIMIT = max(int(os.getenv("NOSTR_LIMIT", "40") or "40"), 1)
MCP_API_BASE = os.getenv("MCP_API_BASE", "https://agent.wooo.work")
MCP_API_KEY = os.getenv("MCP_API_KEY", os.getenv("API_KEY", ""))
MCP_TIMEOUT_SECONDS = max(float(os.getenv("MCP_TIMEOUT_SECONDS", "12") or "12"), 1)
MCP_AGENT_ID = os.getenv("MCP_AGENT_ID", f"ecosystem-hunter-{random.randint(10000, 99999)}")
DEVELOPER_WALLET = os.getenv("DEVELOPER_WALLET", "acct_ecosystem_hunter")
AUTO_CLAIM = os.getenv("AUTO_CLAIM", "false").lower() in {"1", "true", "yes"}
AUTO_SUBMIT = os.getenv("AUTO_SUBMIT", "false").lower() in {"1", "true", "yes"}
AUTO_SUBMIT_PR_URL = os.getenv(
"AUTO_SUBMIT_PR_URL",
"https://github.com/vibework/a2a-ecosystem-hunter/pull/1"
)
SUBMISSION_README = "Automated ecosystem probe submission from external A2A crawler."
SUBMISSION_NOTES = "This is a connectivity probe payload to verify list/open/claim flow."
RUN_DAEMON = os.getenv("RUN_DAEMON", "false").lower() in {"1", "true", "yes"}
RECONNECTION_BACKOFF_SECONDS = max(float(os.getenv("RECONNECTION_BACKOFF_SECONDS", "20") or "20"), 1)
NOSTR_SUB_ID = os.getenv("NOSTR_SUB_ID", f"vh-ecosystem-hunter-{int(time.time())}")
SCAN_INTERVAL_SECONDS = max(float(os.getenv("SCAN_INTERVAL_SECONDS", "0") or "0"), 0)
REPORT_PATH = os.getenv("ECOSYSTEM_REPORT_PATH", "artifacts/ecosystem_hunter_report.jsonl").strip()
KNOWN_ENDPOINTS_ENV = [
item.strip() for item in os.getenv("KNOWN_MCP_ENDPOINTS", "").split(",") if item.strip()
]
REPO_ROOT = Path(os.getenv("APP_ROOT", "").strip() or Path(__file__).resolve().parents[1]).resolve()
ENDPOINTS_FILE = os.getenv(
"MCP_ENDPOINTS_FILE",
str(REPO_ROOT / "scripts" / "ecosystem-hunter-endpoints.txt"),
)
RAW_ENDPOINTS = [item.strip() for item in os.getenv("EXTERNAL_MCP_ENDPOINTS", "").split(",") if item.strip()]
def read_endpoint_file(path: Optional[str]) -> List[str]:
if not path:
return []
file_path = Path(path)
if not file_path.exists():
return []
try:
raw_lines = file_path.read_text(encoding="utf-8").splitlines()
except Exception:
return []
candidates = []
for line in raw_lines:
normalized = line.strip()
if not normalized or normalized.startswith("#"):
continue
candidates.append(normalized)
return candidates
def collect_seed_endpoints() -> List[str]:
seen: Set[str] = set()
ordered: List[str] = []
candidate_groups = [
RAW_ENDPOINTS,
read_endpoint_file(ENDPOINTS_FILE),
KNOWN_ENDPOINTS_ENV,
]
for group in candidate_groups:
for hint in group:
normalized = normalize_endpoint(hint)
if not normalized:
continue
canonical = _dedupe(normalized, seen)
if canonical:
ordered.append(canonical)
if not ordered:
ordered.append(normalize_endpoint(MCP_API_BASE) or MCP_API_BASE)
return ordered
def append_report_line(payload: Dict[str, Any]) -> None:
if not REPORT_PATH:
return
path = Path(REPORT_PATH)
if path.parent and not path.parent.exists():
path.parent.mkdir(parents=True, exist_ok=True)
payload_to_write = {
"ts": now_ts(),
**payload,
}
with path.open("a", encoding="utf-8") as f:
f.write(json.dumps(payload_to_write, ensure_ascii=False) + "\n")
def _dedupe(base: str, seen: Set[str]) -> str:
key = base.lower()
if key in seen:
return ""
seen.add(key)
return base
def now_ts() -> str:
return datetime.now(timezone.utc).isoformat()
def normalize_endpoint(endpoint: str) -> Optional[str]:
if not endpoint:
return None
raw = endpoint.strip()
if not raw:
return None
base = raw.rstrip("/")
if base.endswith("/api/mcp"):
return base
if base.endswith("/mcp"):
return base
if base.endswith("/api"):
return f"{base}/mcp"
if base.endswith("/mcp"):
return base
if base.endswith("/mcp/"):
return base[:-1]
return f"{base}/api/mcp"
def discover_candidate_endpoints(event_payload: Dict[str, Any], tags: Dict[str, str], seen: Set[str]) -> List[str]:
candidates: List[str] = []
endpoint_hints = [
event_payload.get("endpoint"),
event_payload.get("mcp_endpoint"),
event_payload.get("mcp"),
event_payload.get("url"),
]
tag_hint = tags.get("web") or tags.get("r")
if tag_hint:
endpoint_hints.append(tag_hint)
for hint in endpoint_hints:
normalized = normalize_endpoint(str(hint).strip()) if hint else None
if not normalized:
continue
canonical = _dedupe(normalized, seen)
if canonical:
candidates.append(canonical)
if KNOWN_ENDPOINTS_ENV:
for hint in KNOWN_ENDPOINTS_ENV:
normalized = normalize_endpoint(hint)
if not normalized:
continue
canonical = _dedupe(normalized, seen)
if canonical:
candidates.append(canonical)
return candidates
def parse_nostr_tags(tags: Any) -> Dict[str, str]:
tag_map: Dict[str, str] = {}
if not isinstance(tags, list):
return tag_map
for item in tags:
if not isinstance(item, list) or len(item) < 2:
continue
key = str(item[0]).strip().lower()
value = str(item[1]).strip()
if key and value:
# keep the first seen to keep stable behavior
tag_map.setdefault(key, value)
return tag_map
def build_tool_url(base: str, tool: str) -> str:
normalized = normalize_endpoint(base)
if not normalized:
return ""
return f"{normalized}/{tool}"
def read_json(payload: str) -> Optional[Dict[str, Any]]:
if not payload:
return None
try:
parsed = json.loads(payload)
return parsed if isinstance(parsed, dict) else None
except Exception:
return None
def parse_bounty_payload(content: str) -> Dict[str, Any]:
parsed = read_json(content) or {}
if not isinstance(parsed, dict):
return {
"id": None,
"title": "unknown",
"reward": None,
"currency": "USD",
"endpoint": None,
"source": NOSTR_TAG,
"payload": {},
}
bounty = parsed.get("payload") if isinstance(parsed.get("payload"), dict) else parsed.get("bounty", parsed)
if not isinstance(bounty, dict):
bounty = {}
endpoint_hint = bounty.get("endpoint") or parsed.get("endpoint") or parsed.get("mcp_endpoint")
source_hint = bounty.get("source") or parsed.get("source") or NOSTR_TAG
raw_endpoint_list = parsed.get("endpoints")
endpoints: List[str] = []
if isinstance(raw_endpoint_list, list):
endpoints = [str(item).strip() for item in raw_endpoint_list if isinstance(item, str)]
if not isinstance(bounty, dict):
bounty = {}
return {
"id": bounty.get("id"),
"title": bounty.get("title", "unknown"),
"reward": bounty.get("reward"),
"currency": bounty.get("currency", "USD"),
"endpoint": endpoint_hint,
"source": source_hint,
"endpoints": endpoints,
"payload": bounty,
}
def build_headers() -> Dict[str, str]:
return {
"Content-Type": "application/json",
"Authorization": f"Bearer {MCP_API_KEY}",
"x-agent-id": MCP_AGENT_ID,
"x-agent-name": "vibework-ecosystem-hunter",
"x-client-id": "vibework-a2a-hunter",
"User-Agent": "vibework-ecosystem-hunter/1.0",
"x-request-id": f"hunt-{int(time.time() * 1000)}",
}
def post_json(url: str, payload: Dict[str, Any]) -> Dict[str, Any]:
if not MCP_API_KEY:
raise RuntimeError("Missing MCP_API_KEY / API_KEY; set one to run MCP endpoints.")
data = json.dumps(payload).encode("utf-8")
request = urllib.request.Request(url, data=data, headers=build_headers(), method="POST")
try:
with urllib.request.urlopen(request, timeout=MCP_TIMEOUT_SECONDS) as response:
response_body = response.read().decode("utf-8", errors="ignore")
parsed_body = read_json(response_body) or {}
return {
"status": response.status,
"data": parsed_body,
}
except urllib.error.HTTPError as error:
error_body = error.read().decode("utf-8", errors="ignore")
raise RuntimeError(f"HTTP {error.code}: {error_body or error.reason}")
except urllib.error.URLError as error:
raise RuntimeError(f"URLError: {error}")
async def probe_one_endpoint(base_url: str, source: str, source_tag: str) -> None:
normalized = normalize_endpoint(base_url)
if not normalized:
return
print(f"\n[probe] target={normalized} source={source} tag={source_tag}")
try:
list_response = post_json(build_tool_url(normalized, "list_open_tasks"), {"skills": []})
list_data = list_response.get("data", {})
task_count = len(list_data.get("tasks", []) or [])
print(f" ✅ list_open_tasks status={list_response.get('status')} count={task_count}")
append_report_line(
{
"type": "probe",
"target": normalized,
"action": "list_open_tasks",
"source": source,
"source_tag": source_tag,
"status": list_response.get("status"),
"task_count": task_count,
}
)
except Exception as error:
print(f" ⚠️ list_open_tasks failed: {error}")
append_report_line(
{
"type": "probe",
"target": normalized,
"action": "list_open_tasks",
"source": source,
"source_tag": source_tag,
"status": "failed",
"error": str(error),
}
)
return
tasks = list_data.get("tasks", [])
if not isinstance(tasks, list) or not tasks:
return
top_task = tasks[0] if tasks else {}
if not isinstance(top_task, dict):
top_task = {}
task_id = top_task.get("task_id") or top_task.get("id") or "unknown"
task_title = top_task.get("title", "unknown")
print(f" 🧩 top_task={task_id} title={task_title}")
if not AUTO_CLAIM:
return
try:
claim_response = post_json(
build_tool_url(normalized, "claim_task"),
{
"task_id": task_id,
"agent_id": MCP_AGENT_ID,
"developer_wallet": DEVELOPER_WALLET,
},
)
claim_data = claim_response.get("data", {})
claim_token = claim_data.get("claim_token")
print(f" 🧪 claim_task status={claim_response.get('status')} token={str(claim_token)[:10] if claim_token else 'none'}")
append_report_line(
{
"type": "probe",
"target": normalized,
"action": "claim_task",
"source": source,
"source_tag": source_tag,
"status": claim_response.get("status"),
"task_id": task_id,
"has_claim_token": bool(claim_token),
}
)
except Exception as error:
print(f" ⚠️ claim_task failed: {error}")
append_report_line(
{
"type": "probe",
"target": normalized,
"action": "claim_task",
"source": source,
"source_tag": source_tag,
"status": "failed",
"error": str(error),
"task_id": task_id,
}
)
return
if not AUTO_SUBMIT or not claim_token:
return
try:
submit_response = post_json(
build_tool_url(normalized, "submit_solution"),
{
"task_id": task_id,
"claim_token": claim_token,
"deliverables": {
"README.md": SUBMISSION_README,
"notes.txt": SUBMISSION_NOTES,
},
"github_pr_url": AUTO_SUBMIT_PR_URL,
},
)
print(f" 🚀 submit_solution status={submit_response.get('status')}")
append_report_line(
{
"type": "probe",
"target": normalized,
"action": "submit_solution",
"source": source,
"source_tag": source_tag,
"status": submit_response.get("status"),
"task_id": task_id,
}
)
except Exception as error:
print(f" ⚠️ submit_solution failed: {error}")
append_report_line(
{
"type": "probe",
"target": normalized,
"action": "submit_solution",
"source": source,
"source_tag": source_tag,
"status": "failed",
"error": str(error),
"task_id": task_id,
}
)
async def probe_static_endpoints() -> None:
static_endpoints = collect_seed_endpoints()
append_report_line(
{
"type": "scanner_seed_snapshot",
"source": "timer" if SCAN_INTERVAL_SECONDS > 0 else "bootstrap",
"seed_count": len(static_endpoints),
"seeds": static_endpoints,
}
)
for index, base in enumerate(static_endpoints):
await probe_one_endpoint(base, f"seed:{index}", "static")
async def listen_for_bounties() -> None:
if websockets is None:
print("⚠️ websocket support unavailable; skipping Nostr real-time bounty discovery")
return
seen_events = set()
subscribe_message = [
"REQ",
NOSTR_SUB_ID,
{
"kinds": [1],
"#t": [NOSTR_TAG, NOSTR_TAG_FALLBACK],
"limit": NOSTR_LIMIT,
},
]
while True:
print(f"🤖 [Nostr Agent Client] connecting {RELAY_URL} ...")
try:
async with websockets.connect(RELAY_URL) as ws:
print("✅ connected. listening for bounty intents...")
await ws.send(json.dumps(subscribe_message))
async for raw in ws:
try:
data = json.loads(raw)
except Exception:
continue
if not isinstance(data, list) or len(data) < 2 or data[0] != "EVENT":
continue
event = data[2] if len(data) > 2 and isinstance(data[2], dict) else None
if not event:
continue
event_id = event.get("id")
if event_id in seen_events:
continue
seen_events.add(event_id)
if len(seen_events) > 512:
seen_events.pop()
payload = parse_bounty_payload(event.get("content", ""))
tags = parse_nostr_tags(event.get("tags"))
event_seen_targets: Set[str] = set()
event_stats = {
"processed": 0,
"candidates": 0,
"endpoint_hits": 0,
}
endpoint_candidates = discover_candidate_endpoints(payload.get("payload", {}), tags, event_seen_targets)
if payload.get("endpoint"):
candidate = _dedupe(normalize_endpoint(payload.get("endpoint") or "") or "", event_seen_targets)
if candidate:
endpoint_candidates.append(candidate)
extra_endpoints = payload.get("endpoints") or []
if isinstance(extra_endpoints, list):
for hint in extra_endpoints:
normalized = normalize_endpoint(str(hint).strip())
if not normalized:
continue
canonical = _dedupe(normalized, event_seen_targets)
if canonical:
endpoint_candidates.append(canonical)
reward = payload.get("reward")
currency = payload.get("currency", "USD")
task_id = payload.get("id")
title = payload.get("title", "unknown")
print(f"\n🚨 [Nostr bounty] id={task_id} title={title} reward={reward} {currency}")
event_stats["processed"] += 1
if not endpoint_candidates:
print(f"🤖 [Nostr event] id={event_id} no actionable endpoint; skipped")
append_report_line(
{
"type": "event",
"event_id": event_id,
"source": "nostr",
"source_tag": NOSTR_TAG,
"status": "ignored",
"reason": "no_endpoint_candidate",
}
)
continue
for endpoint in endpoint_candidates:
event_stats["candidates"] += 1
await probe_one_endpoint(endpoint, f"nostr:{event_id}", tags.get("t") or NOSTR_TAG)
event_stats["endpoint_hits"] += 1
append_report_line(
{
"type": "event",
"event_id": event_id,
"source": "nostr",
"source_tag": NOSTR_TAG,
"status": "processed",
"candidates": event_stats["candidates"],
"endpoint_hits": event_stats["endpoint_hits"],
}
)
except Exception as error:
print(f"⚠️ Connection failed: {error}")
print(f"⏳ Reconnect in {RECONNECTION_BACKOFF_SECONDS} seconds")
await asyncio.sleep(RECONNECTION_BACKOFF_SECONDS)
async def periodic_static_probe() -> None:
if SCAN_INTERVAL_SECONDS <= 0:
return
while True:
await asyncio.sleep(SCAN_INTERVAL_SECONDS)
append_report_line(
{
"type": "scanner",
"source": "timer",
"status": "tick",
"interval_seconds": SCAN_INTERVAL_SECONDS,
}
)
await probe_static_endpoints()
async def main():
print(f"🛰️ [VibeWork Ecosystem Hunter] started at {now_ts()}")
print(f" MCP base: {MCP_API_BASE}")
print(f" API key configured: {'yes' if MCP_API_KEY else 'no'}")
print(f" Auto claim: {'on' if AUTO_CLAIM else 'off'}")
print(f" Auto submit: {'on' if AUTO_SUBMIT else 'off'}")
print(f" Report file: {REPORT_PATH}")
print(f" Static scan interval: {SCAN_INTERVAL_SECONDS}s")
if KNOWN_ENDPOINTS_ENV:
print(f" Seed endpoints: {', '.join(KNOWN_ENDPOINTS_ENV)}")
await probe_static_endpoints()
if RUN_DAEMON:
if SCAN_INTERVAL_SECONDS > 0:
append_report_line(
{
"type": "bootstrap",
"status": "daemon_started_with_scan",
"scan_interval_seconds": SCAN_INTERVAL_SECONDS,
}
)
if websockets is None:
await periodic_static_probe()
else:
await asyncio.gather(listen_for_bounties(), periodic_static_probe())
else:
append_report_line({"type": "bootstrap", "status": "daemon_started_only_realtime"})
await listen_for_bounties()
else:
append_report_line({"type": "bootstrap", "status": "single_run"})
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,151 @@
import { v4 as uuidv4 } from "uuid";
import OpenAI from "openai";
import dotenv from "dotenv";
dotenv.config();
const API_BASE = process.env.VIBEWORK_API_URL || "http://192.168.0.110:3000/api";
const MCP_API_BASE = process.env.VIBEWORK_MCP_URL || "http://192.168.0.110:3000/api/mcp";
const MCP_API_KEY = process.env.MCP_API_KEY || "super-secret-mcp-key";
// By default use OpenRouter as it aggregates all the best open-source models
const LLM_API_KEY = process.env.OPENROUTER_API_KEY || process.env.OPENAI_API_KEY;
const LLM_BASE_URL = process.env.OPENROUTER_API_KEY ? "https://openrouter.ai/api/v1" : "https://api.openai.com/v1";
if (!LLM_API_KEY) {
console.error("❌ Please provide an OPENROUTER_API_KEY (or OPENAI_API_KEY) in your .env file.");
process.exit(1);
}
const openai = new OpenAI({
baseURL: LLM_BASE_URL,
apiKey: LLM_API_KEY,
defaultHeaders: process.env.OPENROUTER_API_KEY ? {
"HTTP-Referer": "https://agent.wooo.work",
"X-Title": "VibeWork Outbound Dispatcher",
} : undefined
});
// The list of "Mercenary" agents we can hire from the open-source market
const TARGET_AGENTS = [
"nvidia/nemotron-4-340b-instruct",
"nousresearch/nous-hermes-2-mixtral-8x7b-dpo",
"openchat/openchat-7b:free",
"meta-llama/llama-3-70b-instruct"
];
async function callMcpTool(tool: string, payload: any) {
const res = await fetch(`${MCP_API_BASE}/${tool}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${MCP_API_KEY}`
},
body: JSON.stringify(payload)
});
const data = await res.json();
if (!res.ok) {
console.error(`Tool ${tool} failed with status ${res.status}:`, JSON.stringify(data, null, 2));
throw new Error(`Tool ${tool} failed.`);
}
return data;
}
async function main() {
console.log("🚀 [VibeWork Headhunter] Starting outbound dispatch sweep...");
// 1. Find Open Bounties
console.log("🔍 Scanning for OPEN tasks on VibeWork...");
const openTasksRes = await fetch(`${API_BASE}/open-tasks`);
const openTasks = await openTasksRes.json();
if (!openTasks || !openTasks.tasks || openTasks.tasks.length === 0) {
console.log("✅ No open tasks available for dispatch. Resting.");
return;
}
const task = openTasks.tasks[0];
console.log(`🎯 Found Task: "${task.title}" (Reward: ${task.reward_display})`);
// 2. Assign to an external agent
const selectedModel = TARGET_AGENTS[Math.floor(Math.random() * TARGET_AGENTS.length)];
const agentId = `outbound-${selectedModel.split("/").pop()}-${Math.floor(Math.random() * 1000)}`;
console.log(`🤖 Dispatching to external agent: ${selectedModel} (Agent ID: ${agentId})`);
// 3. Claim the task on VibeWork
console.log("\n💰 Claiming Task on behalf of the agent...");
const claimRes = await callMcpTool("claim_task", {
task_id: task.task_id,
agent_id: agentId,
// We provide a mock Stripe Connect Account or real one if configured
developer_wallet: "acct_1MockStripeOutboundAgent"
});
console.log("✅ Claimed successfully. Token:", claimRes.claim_token);
// 4. Prompt the Model to solve it
console.log(`\n🧠 Consulting ${selectedModel} for the solution...`);
const systemPrompt = `You are an elite autonomous AI developer.
You have just accepted a bounty on VibeWork.
You must solve the task by writing the necessary code.
Return ONLY valid JSON in the following format, with no markdown formatting outside the JSON block:
{
"deliverables": {
"filename1.ts": "code content here",
"filename2.md": "documentation here"
}
}
Do not return any explanations, just the JSON string.`;
const userPrompt = `TASK TITLE: ${task.title}
TASK DESCRIPTION: ${task.description_preview}
Please provide the solution.`;
try {
const completion = await openai.chat.completions.create({
model: selectedModel,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt }
],
temperature: 0.2,
response_format: { type: "json_object" }
});
const outputContent = completion.choices[0].message.content || "{}";
let solutionObj;
try {
solutionObj = JSON.parse(outputContent);
} catch (e) {
console.warn("⚠️ Failed to parse LLM JSON output. Falling back to raw text mapping.", outputContent);
solutionObj = {
deliverables: {
"solution.txt": outputContent
}
};
}
if (!solutionObj.deliverables) {
solutionObj.deliverables = { "solution.txt": outputContent };
}
console.log("✅ Model generated the solution deliverables:", Object.keys(solutionObj.deliverables));
// 5. Auto-Submit the Solution
console.log("\n🚀 Submitting the solution back to VibeWork...");
const fakePrUrl = `https://github.com/agent-bounty/external-agents/pull/${Math.floor(Math.random() * 10000)}`;
const solutionRes = await callMcpTool("submit_solution", {
task_id: task.task_id,
claim_token: claimRes.claim_token,
deliverables: solutionObj.deliverables,
github_pr_url: fakePrUrl
});
console.log(`🎉 Success! Solution submitted. Submission ID: ${solutionRes.submission_id}`);
} catch (err) {
console.error("❌ LLM API Error during dispatch:", err);
}
}
main().catch(console.error);

54
scripts/run_ecosystem_hunter.sh Executable file
View File

@@ -0,0 +1,54 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
ENV_FILE="${1:-$SCRIPT_DIR/ecosystem-hunter.env}"
if [[ -f "$ENV_FILE" ]]; then
# shellcheck disable=SC1090
source "$ENV_FILE"
fi
export PYTHON_BIN="${PYTHON_BIN:-/usr/bin/python3}"
export APP_ROOT="${APP_ROOT:-$REPO_ROOT}"
export MCP_API_BASE="${MCP_API_BASE:-https://agent.wooo.work}"
export MCP_API_KEY="${MCP_API_KEY:-}"
export MCP_AGENT_ID="${MCP_AGENT_ID:-vibe-hunter-$(date +%s)}"
export DEVELOPER_WALLET="${DEVELOPER_WALLET:-acct_ecosystem_hunter}"
export AUTO_CLAIM="${AUTO_CLAIM:-false}"
export AUTO_SUBMIT="${AUTO_SUBMIT:-false}"
export AUTO_SUBMIT_PR_URL="${AUTO_SUBMIT_PR_URL:-https://github.com/vibework/a2a-ecosystem-hunter/pull/1}"
export RUN_DAEMON="${RUN_DAEMON:-true}"
export SCAN_INTERVAL_SECONDS="${SCAN_INTERVAL_SECONDS:-300}"
export RECONNECTION_BACKOFF_SECONDS="${RECONNECTION_BACKOFF_SECONDS:-20}"
export NOSTR_RELAY_URL="${NOSTR_RELAY_URL:-wss://relay.damus.io}"
export NOSTR_TAG="${NOSTR_TAG:-VibeWork_Bounty}"
export NOSTR_LIMIT="${NOSTR_LIMIT:-40}"
export EXTERNAL_MCP_ENDPOINTS="${EXTERNAL_MCP_ENDPOINTS:-$MCP_API_BASE}"
export KNOWN_MCP_ENDPOINTS="${KNOWN_MCP_ENDPOINTS:-}"
export MCP_TIMEOUT_SECONDS="${MCP_TIMEOUT_SECONDS:-12}"
export ECOSYSTEM_REPORT_PATH="${ECOSYSTEM_REPORT_PATH:-$REPO_ROOT/artifacts/ecosystem_hunter_report.jsonl}"
mkdir -p "$REPO_ROOT/artifacts"
export REPORT_LOG_DIR="${REPORT_LOG_DIR:-$REPO_ROOT/.local/logs/agent-bounty-ecosystem-hunter}"
mkdir -p "$REPORT_LOG_DIR"
cd "$REPO_ROOT"
if [[ ! -x "$PYTHON_BIN" ]]; then
echo "[runner] PYTHON_BIN not executable: $PYTHON_BIN"
exit 1
fi
if [[ -z "${MCP_API_KEY}" ]]; then
echo "[runner] MCP_API_KEY is empty. Set MCP_API_KEY before running."
exit 1
fi
STDOUT_LOG="${STDOUT_LOG:-$REPORT_LOG_DIR/ecosystem-hunter.stdout.log}"
STDERR_LOG="${STDERR_LOG:-$REPORT_LOG_DIR/ecosystem-hunter.stderr.log}"
exec "$PYTHON_BIN" scripts/nostr_agent_client.py \
>>"$STDOUT_LOG" \
2>>"$STDERR_LOG"

View File

@@ -0,0 +1,16 @@
[Unit]
Description=Agent Bounty Ecosystem Hunter (Nostr + MCP external probe)
After=network.target
[Service]
Type=simple
WorkingDirectory=/home/ollama/vibework-git
EnvironmentFile=/home/ollama/vibework-git/scripts/ecosystem-hunter.env
ExecStart=/home/ollama/vibework-git/scripts/run_ecosystem_hunter.sh /home/ollama/vibework-git/scripts/ecosystem-hunter.env
Restart=always
RestartSec=20
StandardOutput=append:/home/ollama/vibework-git/.local/logs/agent-bounty-ecosystem-hunter/service.log
StandardError=append:/home/ollama/vibework-git/.local/logs/agent-bounty-ecosystem-hunter/service.log
[Install]
WantedBy=default.target