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
Some checks failed
Deploy to 110 WOOO Server / deploy (push) Failing after 8s
This commit is contained in:
78
README.md
78
README.md
@@ -21,6 +21,9 @@ git pull origin main
|
||||
E2B_API_KEY="your-e2b-key"
|
||||
# 供 MCP Server 認證使用的 API Key
|
||||
API_KEY="your-secure-mcp-key"
|
||||
# 後台帳號(可在環境變數覆蓋);未設定時使用 wooo / 0936223270 作為維運預設值
|
||||
ADMIN_USERNAME="wooo"
|
||||
ADMIN_PASSWORD="0936223270"
|
||||
# Scout Bot:提供 GitHub Token,可避免 API 速率限制並能真正貼上 comment
|
||||
GITHUB_TOKEN="github_pat_..."
|
||||
# 監控告警:外部導流/外部操作事件 webhook(可留空)
|
||||
@@ -102,3 +105,78 @@ sudo certbot --nginx -d agent.wooo.work
|
||||
}
|
||||
```
|
||||
這樣 AI Agent 呼叫 Tool 時,就會直接連線回 110 主機上的 Next.js 閘道器了!
|
||||
|
||||
### 5. 外部 A2A 生態圈探測腳本(Nostr + MCP)
|
||||
|
||||
`scripts/nostr_agent_client.py` 已可直接監聽 Nostr 與對外部 MCP 端點做真實 `list_open_tasks / claim_task / submit_solution` 行為驗證(可控開關)。
|
||||
|
||||
```bash
|
||||
cd /Users/ogt/Documents/agent-bounty-protocol
|
||||
source venv/bin/activate
|
||||
|
||||
# 1) 只做觀察(不 claim / submit)
|
||||
export MCP_API_KEY="vw_beta_promo_2026"
|
||||
python scripts/nostr_agent_client.py
|
||||
|
||||
# 2) 允許自動 claim
|
||||
export AUTO_CLAIM=true
|
||||
python scripts/nostr_agent_client.py
|
||||
|
||||
# 3) 允許 auto claim + submit(注意會產生可追溯的外部行為)
|
||||
export AUTO_CLAIM=true
|
||||
export AUTO_SUBMIT=true
|
||||
export RUN_DAEMON=true
|
||||
python scripts/nostr_agent_client.py
|
||||
```
|
||||
|
||||
可透過環境變數延展觀測來源:
|
||||
|
||||
- `EXTERNAL_MCP_ENDPOINTS`(逗號分隔,如 `https://agent.wooo.work`)
|
||||
- `KNOWN_MCP_ENDPOINTS`(額外種子清單:可放入你已知的外部 MCP 入口)
|
||||
- `MCP_ENDPOINTS_FILE`(額外端點檔,一行一個,預設 `scripts/ecosystem-hunter-endpoints.txt`)
|
||||
- `NOSTR_RELAY_URL`(預設 `wss://relay.damus.io`)
|
||||
- `NOSTR_TAG`(預設 `VibeWork_Bounty`)
|
||||
- `RECONNECTION_BACKOFF_SECONDS`(預設 20)
|
||||
- `DEVELOPER_WALLET`(預設 `acct_ecosystem_hunter`)
|
||||
- `RUN_DAEMON=true`(啟用 Nostr 監聽長駐)
|
||||
- `SCAN_INTERVAL_SECONDS`(長駐模式下每 N 秒再掃描種子入口,0=只跑一次)
|
||||
- `ECOSYSTEM_REPORT_PATH`(寫入互動報表 JSONL,預設 `artifacts/ecosystem_hunter_report.jsonl`)
|
||||
- `AUTO_CLAIM` / `AUTO_SUBMIT`(控制是否真的呼叫 claim/submit)
|
||||
- `AUTO_SUBMIT_PR_URL`(可自訂測試用 PR URL)
|
||||
|
||||
可直接抓外部真實流量快照:
|
||||
|
||||
```bash
|
||||
./scripts/monitor_external_traffic.sh https://agent.wooo.work 60
|
||||
```
|
||||
|
||||
#### 5.1 持續巡檢(daemon)部署到 188 主機
|
||||
|
||||
已提供可直接落地的啟動腳本與 systemd 標準化設定:
|
||||
|
||||
1. 複製 `scripts/ecosystem-hunter.env.example` 成 `scripts/ecosystem-hunter.env`,填入正式金鑰與參數
|
||||
2. 把 env 與服務檔放到主機(假設 repo 在 `/home/ollama/vibework-git`)
|
||||
|
||||
```bash
|
||||
cp scripts/ecosystem-hunter.env.example scripts/ecosystem-hunter.env
|
||||
[ -f scripts/ecosystem-hunter-endpoints.txt ] || cat <<'EOF' > scripts/ecosystem-hunter-endpoints.txt
|
||||
https://agent.wooo.work
|
||||
EOF
|
||||
./scripts/deploy_ecosystem_hunter.sh
|
||||
```
|
||||
|
||||
3. 腳本會建立 user-level systemd 並啟動服務(不需 sudo)
|
||||
|
||||
```bash
|
||||
systemctl --user status agent-bounty-ecosystem-hunter.service
|
||||
```
|
||||
|
||||
4. 觀察巡檢輸出與 JSONL 報表
|
||||
|
||||
```bash
|
||||
systemctl --user status agent-bounty-ecosystem-hunter.service
|
||||
tail -f /home/ollama/vibework-git/.local/logs/agent-bounty-ecosystem-hunter/service.log
|
||||
tail -f artifacts/ecosystem_hunter_report.jsonl
|
||||
```
|
||||
|
||||
> 建議先以 `AUTO_CLAIM=false` 上線,確認 `list_open_tasks` 有進入外部活動後,再打開 claim/submit。
|
||||
|
||||
@@ -1,39 +1,209 @@
|
||||
import { VibeWorkAgentSDK } from '@vibework/agent-sdk';
|
||||
import { ClaimTaskResponse, SubmitSolutionRequest, TaskBounty } from '@vibework/agent-sdk';
|
||||
import {
|
||||
VibeWorkAgentSDK,
|
||||
ClaimTaskResponse,
|
||||
QueryAgentMemoryRequest,
|
||||
SubmitSolutionRequest,
|
||||
TaskBounty,
|
||||
} from '@vibework/agent-sdk';
|
||||
import 'dotenv/config';
|
||||
|
||||
type A2AAction = 'query-memory' | 'create-sub-task' | 'peer-review' | 'help-signal' | 'rent-resource';
|
||||
|
||||
interface A2AConfig {
|
||||
enabled: boolean;
|
||||
sequence: A2AAction[];
|
||||
maxActionsPerCycle: number;
|
||||
helpErrorMessage: string;
|
||||
peerReviewSnippet: string;
|
||||
rentDurationMinutes: number;
|
||||
}
|
||||
|
||||
function resolveEnv(name: string, fallback: string): string {
|
||||
return process.env[name]?.trim() || fallback;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("🤖 Starting VibeWork Test Agent...");
|
||||
function parseIntEnv(name: string, fallback: number): number {
|
||||
const raw = process.env[name]?.trim();
|
||||
if (!raw) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const baseUrl = resolveEnv('VIBEWORK_API_URL', 'http://localhost:3000');
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function parseA2ASequence(value: string, fallback: A2AAction[]): A2AAction[] {
|
||||
const aliases: Record<string, A2AAction> = {
|
||||
memory: 'query-memory',
|
||||
'query-memory': 'query-memory',
|
||||
query: 'query-memory',
|
||||
'create-sub-task': 'create-sub-task',
|
||||
subtask: 'create-sub-task',
|
||||
'peer-review': 'peer-review',
|
||||
review: 'peer-review',
|
||||
'help-signal': 'help-signal',
|
||||
help: 'help-signal',
|
||||
rent: 'rent-resource',
|
||||
'rent-resource': 'rent-resource',
|
||||
};
|
||||
|
||||
const parsed: A2AAction[] = [];
|
||||
for (const token of value.split(',').map((item) => item.trim().toLowerCase()).filter(Boolean)) {
|
||||
const action = aliases[token];
|
||||
if (action && !parsed.includes(action)) {
|
||||
parsed.push(action);
|
||||
}
|
||||
}
|
||||
return parsed.length ? parsed : fallback;
|
||||
}
|
||||
|
||||
function getA2AConfig(): A2AConfig {
|
||||
const defaultSequence: A2AAction[] = ['query-memory', 'create-sub-task', 'peer-review', 'rent-resource'];
|
||||
const enabled = resolveEnv('VIBEWORK_A2A_ENABLED', 'true').toLowerCase() === 'true';
|
||||
const sequence = parseA2ASequence(process.env.VIBEWORK_A2A_SEQUENCE || '', defaultSequence);
|
||||
|
||||
return {
|
||||
enabled,
|
||||
sequence,
|
||||
maxActionsPerCycle: parseIntEnv('VIBEWORK_A2A_MAX_ACTIONS_PER_CYCLE', 1),
|
||||
helpErrorMessage: resolveEnv(
|
||||
'VIBEWORK_A2A_HELP_ERROR',
|
||||
'Stuck while reproducing external failure case'
|
||||
),
|
||||
peerReviewSnippet: resolveEnv(
|
||||
'VIBEWORK_A2A_REVIEW_SNIPPET',
|
||||
'function demo() { return "ok"; }'
|
||||
),
|
||||
rentDurationMinutes: parseIntEnv('VIBEWORK_A2A_RENT_MINUTES', 5),
|
||||
};
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function runA2AProbe(
|
||||
sdk: VibeWorkAgentSDK,
|
||||
agentId: string,
|
||||
targetTask: TaskBounty,
|
||||
claimResult: ClaimTaskResponse,
|
||||
config: A2AConfig,
|
||||
cycle: number,
|
||||
) {
|
||||
const actions = config.sequence.slice(0, config.maxActionsPerCycle);
|
||||
if (!actions.length) {
|
||||
console.log('🕊️ A2A sequence is empty, skip probe.');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const action of actions) {
|
||||
if (action === 'query-memory') {
|
||||
const request: QueryAgentMemoryRequest = {
|
||||
query: `open_task_lookup ${targetTask.title}`,
|
||||
error_code: 'A2A_TEST_DRILL',
|
||||
};
|
||||
const memory = await sdk.a2a.queryAgentMemory(request);
|
||||
console.log(`🧠 Memory probe: ${memory.results.length} results`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (action === 'create-sub-task') {
|
||||
if (claimResult.held_amount <= 1) {
|
||||
console.log('⚠️ Skip create-sub-task: held_amount too small.');
|
||||
continue;
|
||||
}
|
||||
const rewardAmount = Math.min(
|
||||
claimResult.held_amount - 1,
|
||||
Math.max(1, Math.floor(claimResult.held_amount / 10))
|
||||
);
|
||||
const created = await sdk.a2a.createSubTask({
|
||||
parent_task_id: targetTask.task_id,
|
||||
claim_token: claimResult.claim_token,
|
||||
title: `[A2A Drill][${cycle}] ${targetTask.title.slice(0, 20)}`,
|
||||
description:
|
||||
'Automated helper sub-task generated by test agent for A2A communication verification.',
|
||||
reward_amount: rewardAmount,
|
||||
acceptance_criteria: {
|
||||
validation_mode: 'AST_PARSING',
|
||||
test_file_content: `// A2A helper stub\nexport const a2aTask = () => "${targetTask.task_id}";`,
|
||||
rules: [
|
||||
{
|
||||
assertion: 'helper_export_exists',
|
||||
expected: true,
|
||||
description: 'helper function should exist',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
console.log(`🧩 Sub-task created: ${created.sub_task_id} (${created.status})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (action === 'peer-review') {
|
||||
const review = await sdk.a2a.requestPeerReview({
|
||||
parent_task_id: targetTask.task_id,
|
||||
claim_token: claimResult.claim_token,
|
||||
code_snippet: config.peerReviewSnippet,
|
||||
review_instructions: `Run a quick static review for task ${targetTask.task_id}.`,
|
||||
});
|
||||
console.log(`🧾 Peer-review task created: ${review.review_task_id}, cost=${review.cost}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (action === 'help-signal') {
|
||||
const sos = await sdk.a2a.broadcastHelpSignal({
|
||||
parent_task_id: targetTask.task_id,
|
||||
claim_token: claimResult.claim_token,
|
||||
error_message: config.helpErrorMessage,
|
||||
contextual_code: `function fallback() { return 'retry later'; } // cycle=${cycle}`,
|
||||
});
|
||||
console.log(`🆘 Help signal emitted: ${sos.sos_task_id} (${sos.status})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (action === 'rent-resource') {
|
||||
const rent = await sdk.a2a.rentApiResource({
|
||||
agent_id: agentId,
|
||||
resource_type: 'GPT_4O',
|
||||
duration_minutes: config.rentDurationMinutes,
|
||||
});
|
||||
console.log(`📦 Rent resource result: ${rent.status} - ${rent.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('🤖 Starting VibeWork Test Agent...');
|
||||
|
||||
const baseUrl = resolveEnv('VIBEWORK_API_URL', 'https://agent.wooo.work');
|
||||
const apiKey = process.env.VIBEWORK_API_KEY;
|
||||
const agentId = resolveEnv('VIBEWORK_AGENT_ID', 'test-hunter-bot-001');
|
||||
const wallet = resolveEnv('VIBEWORK_AGENT_WALLET', '0x1234567890abcdef1234567890abcdef12345678');
|
||||
const agentName = resolveEnv('VIBEWORK_AGENT_NAME', 'HunterBot-Test');
|
||||
const githubPrUrl = resolveEnv(
|
||||
'VIBEWORK_PR_URL',
|
||||
'https://github.com/agent-bounty-protocol/pr/123'
|
||||
);
|
||||
const iterationLimit = Number(process.env.VIBEWORK_MAX_ITERATIONS ?? '1');
|
||||
const sleepMs = Number(process.env.VIBEWORK_SIMULATE_WORK_MS ?? '3000');
|
||||
const iterationLimit = parseIntEnv('VIBEWORK_MAX_ITERATIONS', 1);
|
||||
const sleepMs = parseIntEnv('VIBEWORK_SIMULATE_WORK_MS', 3000);
|
||||
const a2aConfig = getA2AConfig();
|
||||
|
||||
const sdk = new VibeWorkAgentSDK({
|
||||
baseUrl,
|
||||
apiKey,
|
||||
agentId,
|
||||
agentName,
|
||||
});
|
||||
|
||||
try {
|
||||
console.log("📝 Registering Agent Identity...");
|
||||
console.log('📝 Registering Agent Identity...');
|
||||
const registerResult = await sdk.identity.registerAgent({
|
||||
agent_id: agentId,
|
||||
name: resolveEnv("VIBEWORK_AGENT_NAME", "HunterBot-Test"),
|
||||
name: agentName,
|
||||
description:
|
||||
"A test agent built with @vibework/agent-sdk to hunt for bounties autonomously.",
|
||||
supported_models: ["gpt-4o"],
|
||||
skills: ["typescript", "javascript", "react", "testing"],
|
||||
'A test agent built with @vibework/agent-sdk to run A2A drill traffic and verify MCP interoperability.',
|
||||
supported_models: ['gpt-4o'],
|
||||
skills: ['typescript', 'javascript', 'react', 'testing', 'a2a'],
|
||||
max_concurrent_tasks: 3,
|
||||
x402_wallet_address: wallet,
|
||||
});
|
||||
@@ -42,27 +212,41 @@ async function main() {
|
||||
let iteration = 0;
|
||||
while (iteration < iterationLimit) {
|
||||
iteration += 1;
|
||||
console.log(`\n[Cycle ${iteration}] 🔍 Scanning for open bounties...`);
|
||||
const openBounties = await sdk.tasks.listOpenBounties(5);
|
||||
console.log(`🎯 Found ${openBounties.length} open bounties.`);
|
||||
console.log(`\n[Cycle ${iteration}] 🔍 Scanning for open bounties through MCP...`);
|
||||
|
||||
const openBountiesResp = await sdk.a2a.listOpenBounties(8);
|
||||
const openBounties = openBountiesResp.tasks as TaskBounty[];
|
||||
console.log(`🎯 Found ${openBounties.length} open bounties (stockout=${openBountiesResp.stockout_warning})`);
|
||||
|
||||
if (!openBounties.length) {
|
||||
console.log("😴 No open bounties found. Exit.");
|
||||
return;
|
||||
console.log('😴 No MCP-open tasks, run visibility heartbeat only.');
|
||||
const heartbeat = await sdk.a2a.queryAgentMemory({
|
||||
query: `open-tasks empty cycle=${iteration}`,
|
||||
error_code: 'EMPTY_BOARD_DRILL',
|
||||
});
|
||||
console.log(`📡 Visibility heartbeat: memory hits ${heartbeat.results.length}`);
|
||||
await sleep(1500);
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetTask = openBounties[0] as TaskBounty;
|
||||
const targetTask = openBounties[0];
|
||||
console.log(
|
||||
`📌 Target: [${targetTask.task_id}] ${targetTask.title} (Reward: ${
|
||||
targetTask.reward?.display_amount ?? targetTask.reward_display ?? 'n/a'
|
||||
})`
|
||||
);
|
||||
|
||||
const claimResult: ClaimTaskResponse = await sdk.tasks.claimBounty(targetTask.task_id, agentId, wallet);
|
||||
const claimResult = await sdk.tasks.claimBounty(targetTask.task_id, agentId, wallet);
|
||||
console.log(`✅ Bounty claimed. Claim token prefix: ${claimResult.claim_token.slice(0, 10)}...`);
|
||||
|
||||
console.log(`⏳ Working on task ${targetTask.task_id}...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, sleepMs));
|
||||
await sleep(sleepMs);
|
||||
|
||||
if (a2aConfig.enabled) {
|
||||
await runA2AProbe(sdk, agentId, targetTask, claimResult, a2aConfig, iteration);
|
||||
} else {
|
||||
console.log('🔒 A2A drill disabled, skipping external MCP interactions.');
|
||||
}
|
||||
|
||||
const submitPayload: SubmitSolutionRequest = {
|
||||
task_id: targetTask.task_id,
|
||||
@@ -77,9 +261,9 @@ async function main() {
|
||||
console.log(`🎉 Submit done. Status: ${submitResult.status}, submission_id=${submitResult.submission_id}`);
|
||||
}
|
||||
|
||||
console.log("🤖 Agent cycles complete.");
|
||||
console.log('🤖 Agent cycles complete.');
|
||||
} catch (err: any) {
|
||||
console.error("❌ Agent encountered an error:", err?.response?.data || err.message || err);
|
||||
console.error('❌ Agent encountered an error:', err?.response?.data || err.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
14
apps/web/prisma.config.ts.bak
Normal file
14
apps/web/prisma.config.ts.bak
Normal 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,
|
||||
},
|
||||
});
|
||||
18
apps/web/src/app/admin/page.tsx
Normal file
18
apps/web/src/app/admin/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
2
apps/web/src/app/admin/traffic/page.tsx
Normal file
2
apps/web/src/app/admin/traffic/page.tsx
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default } from "../../traffic/page";
|
||||
|
||||
48
apps/web/src/app/api/admin/health/route.ts
Normal file
48
apps/web/src/app/api/admin/health/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -148,6 +148,15 @@ function resolveSourceIp(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
function isPublicRequest(request: NextRequest) {
|
||||
const sourceIp = resolveSourceIp(request);
|
||||
return !isPrivateIp(sourceIp);
|
||||
}
|
||||
|
||||
function scopeTrafficAction(baseAction: string, isPublicIp: boolean) {
|
||||
return `${isPublicIp ? "EXTERNAL" : "INTERNAL"}_${baseAction}`;
|
||||
}
|
||||
|
||||
async function ensureBuilderAgent(
|
||||
agentId: string,
|
||||
requestContext?: {
|
||||
@@ -155,7 +164,8 @@ async function ensureBuilderAgent(
|
||||
source_ip?: string;
|
||||
user_agent?: string;
|
||||
request_actor_headers?: Record<string, unknown>;
|
||||
}
|
||||
},
|
||||
isPublicIp = false
|
||||
) {
|
||||
const existingAgent = await prisma.agentProfile.findUnique({ where: { agent_id: agentId } });
|
||||
if (existingAgent) {
|
||||
@@ -177,11 +187,11 @@ async function ensureBuilderAgent(
|
||||
|
||||
void sendTrafficAlert({
|
||||
level: "info",
|
||||
action: "EXTERNAL_AGENT_AUTO_WHITELIST",
|
||||
action: scopeTrafficAction("AGENT_AUTO_WHITELIST", isPublicIp),
|
||||
surface: "mcp/claim_task",
|
||||
actorType: "AGENT",
|
||||
actorId: `agent:${normalizeActorId(agentId, "agent")}`,
|
||||
message: `外部 Agent 首次接案已自動白名單: ${agentId}`,
|
||||
message: `Agent 首次接案已自動白名單: ${agentId}`,
|
||||
metadata: {
|
||||
...requestContext,
|
||||
agent_id: agentId,
|
||||
@@ -238,6 +248,11 @@ function normalizeActorId(value: string, fallback: string) {
|
||||
return normalized.slice(0, 64) || fallback;
|
||||
}
|
||||
|
||||
function asAgentActorId(value: string | undefined, fallback = "agent") {
|
||||
const normalized = normalizeActorId(value || fallback, fallback);
|
||||
return normalized.startsWith("agent:") ? normalized : `agent:${normalized}`;
|
||||
}
|
||||
|
||||
function resolveActorFromMcpRequest(request: NextRequest) {
|
||||
for (const headerName of MCP_AGENT_HEADERS) {
|
||||
const headerValue = request.headers.get(headerName);
|
||||
@@ -260,12 +275,13 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
const tool = params.tool;
|
||||
const actor = resolveActorFromMcpRequest(request);
|
||||
const requestContext = resolveRequestTrace(request);
|
||||
const isPublicIp = isPublicRequest(request);
|
||||
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||
void sendTrafficAlert({
|
||||
level: "warning",
|
||||
action: "EXTERNAL_MCP_AUTH_MISSING",
|
||||
action: scopeTrafficAction("MCP_AUTH_MISSING", isPublicIp),
|
||||
surface: `mcp/${tool}`,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
@@ -292,7 +308,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
if (!isValidServerKey && !isBetaToken) {
|
||||
void sendTrafficAlert({
|
||||
level: "warning",
|
||||
action: "EXTERNAL_MCP_AUTH_FORBIDDEN",
|
||||
action: scopeTrafficAction("MCP_AUTH_FORBIDDEN", isPublicIp),
|
||||
surface: `mcp/${tool}`,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
@@ -301,6 +317,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
metadata: {
|
||||
...requestContext,
|
||||
auth_issue: "invalid_bearer_token",
|
||||
payload_summary: summarizeRequestPayload(tool, null),
|
||||
response_summary: "invalid_bearer_token",
|
||||
response_status: 403,
|
||||
},
|
||||
@@ -325,9 +342,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
(body as Record<string, unknown>).skills = [];
|
||||
}
|
||||
ListOpenTasksRequestSchema.parse(body);
|
||||
const sourceIp = resolveSourceIp(request);
|
||||
const isPublicIp = !isPrivateIp(sourceIp);
|
||||
const trafficAction = isPublicIp ? "EXTERNAL_LIST_OPEN_TASKS_MCP" : "INTERNAL_LIST_OPEN_TASKS_MCP";
|
||||
const trafficAction = scopeTrafficAction("LIST_OPEN_TASKS_MCP", isPublicIp);
|
||||
const tasks = await prisma.task.findMany({
|
||||
where: {
|
||||
status: TaskStatus.OPEN,
|
||||
@@ -393,7 +408,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
if (eventCount > 0 && eventCount % MCP_SURGE_INTERVAL === 0) {
|
||||
void sendTrafficAlert({
|
||||
level: "warning",
|
||||
action: isPublicIp ? "EXTERNAL_LIST_OPEN_TASKS_SURGE" : "INTERNAL_LIST_OPEN_TASKS_SURGE",
|
||||
action: scopeTrafficAction("LIST_OPEN_TASKS_SURGE", isPublicIp),
|
||||
surface: "mcp/list_open_tasks",
|
||||
actorType: "SYSTEM",
|
||||
actorId: "traffic-monitor",
|
||||
@@ -420,11 +435,11 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
const parsed = ClaimTaskRequestSchema.parse(body);
|
||||
|
||||
// Verify Agent Whitelist
|
||||
const agent = await ensureBuilderAgent(parsed.agent_id, requestContext);
|
||||
const agent = await ensureBuilderAgent(parsed.agent_id, requestContext, isPublicIp);
|
||||
if (!agent) {
|
||||
void sendTrafficAlert({
|
||||
level: "warning",
|
||||
action: "EXTERNAL_CLAIM_TASK_FORBIDDEN",
|
||||
action: scopeTrafficAction("CLAIM_TASK_FORBIDDEN", isPublicIp),
|
||||
surface: "mcp/claim_task",
|
||||
actorType: "AGENT",
|
||||
actorId: `agent:${normalizeActorId(parsed.agent_id, "agent")}`,
|
||||
@@ -444,7 +459,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
if (agent.status !== "WHITELISTED") {
|
||||
void sendTrafficAlert({
|
||||
level: "warning",
|
||||
action: "EXTERNAL_CLAIM_TASK_FORBIDDEN",
|
||||
action: scopeTrafficAction("CLAIM_TASK_FORBIDDEN", isPublicIp),
|
||||
surface: "mcp/claim_task",
|
||||
actorType: "AGENT",
|
||||
actorId: `agent:${normalizeActorId(parsed.agent_id, "agent")}`,
|
||||
@@ -510,7 +525,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
|
||||
void sendTrafficAlert({
|
||||
level: "info",
|
||||
action: "EXTERNAL_CLAIM_TASK_SUCCESS",
|
||||
action: scopeTrafficAction("CLAIM_TASK_SUCCESS", isPublicIp),
|
||||
surface: "mcp/claim_task",
|
||||
actorType: "AGENT",
|
||||
actorId: `agent:${normalizeActorId(parsed.agent_id, "agent")}`,
|
||||
@@ -528,10 +543,12 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
},
|
||||
});
|
||||
|
||||
void evaluateExternalFunnelHealth({
|
||||
surface: "mcp/claim_task",
|
||||
periodMinutes: 10,
|
||||
});
|
||||
if (isPublicIp) {
|
||||
void evaluateExternalFunnelHealth({
|
||||
surface: "mcp/claim_task",
|
||||
periodMinutes: 10,
|
||||
});
|
||||
}
|
||||
|
||||
// Set Redis TTL key (3600 seconds)
|
||||
await redis.set(`vw:task:${claim.task_id}:executing`, claim.claim_token, "EX", 3600);
|
||||
@@ -608,10 +625,10 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
|
||||
void sendTrafficAlert({
|
||||
level: "info",
|
||||
action: "EXTERNAL_SUBMIT_SOLUTION_SUCCESS",
|
||||
action: scopeTrafficAction("SUBMIT_SOLUTION_SUCCESS", isPublicIp),
|
||||
surface: "mcp/submit_solution",
|
||||
actorType: "AGENT",
|
||||
actorId: submittedClaim.agent_id,
|
||||
actorId: asAgentActorId(submittedClaim.agent_id),
|
||||
taskId: submission.task_id,
|
||||
message: `Agent 提交解法: ${parsed.task_id}`,
|
||||
metadata: {
|
||||
@@ -626,10 +643,12 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
},
|
||||
});
|
||||
|
||||
void evaluateExternalFunnelHealth({
|
||||
surface: "mcp/submit_solution",
|
||||
periodMinutes: 10,
|
||||
});
|
||||
if (isPublicIp) {
|
||||
void evaluateExternalFunnelHealth({
|
||||
surface: "mcp/submit_solution",
|
||||
periodMinutes: 10,
|
||||
});
|
||||
}
|
||||
|
||||
// Async trigger E2B Sandbox evaluation
|
||||
const taskObj = await prisma.task.findUnique({ where: { id: submission.task_id }});
|
||||
@@ -784,10 +803,10 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
|
||||
void sendTrafficAlert({
|
||||
level: "info",
|
||||
action: "EXTERNAL_CREATE_SUB_TASK_SUCCESS",
|
||||
action: scopeTrafficAction("CREATE_SUB_TASK_SUCCESS", isPublicIp),
|
||||
surface: "mcp/create_sub_task",
|
||||
actorType: "AGENT",
|
||||
actorId: subTask.created_by_agent!,
|
||||
actorId: asAgentActorId(subTask.created_by_agent || parsed.parent_task_id),
|
||||
taskId: subTask.id,
|
||||
message: `A2A 內循環!Agent 發佈了子任務: ${subTask.id}`,
|
||||
metadata: {
|
||||
@@ -863,10 +882,10 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
|
||||
void sendTrafficAlert({
|
||||
level: "info",
|
||||
action: "EXTERNAL_PEER_REVIEW_REQUEST",
|
||||
action: scopeTrafficAction("PEER_REVIEW_REQUEST", isPublicIp),
|
||||
surface: "mcp/request_peer_review",
|
||||
actorType: "AGENT",
|
||||
actorId: reviewTask.created_by_agent!,
|
||||
actorId: asAgentActorId(reviewTask.created_by_agent || undefined),
|
||||
taskId: reviewTask.id,
|
||||
message: `A2A 互助!Agent 發佈了 Code Review 任務: ${reviewTask.id}`,
|
||||
metadata: {
|
||||
@@ -958,7 +977,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
|
||||
void sendTrafficAlert({
|
||||
level: "info",
|
||||
action: "EXTERNAL_AGENT_MEMORY_QUERY",
|
||||
action: scopeTrafficAction("AGENT_MEMORY_QUERY", isPublicIp),
|
||||
surface: "mcp/query_agent_memory",
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
@@ -1071,7 +1090,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
if (!ledger) {
|
||||
void sendTrafficAlert({
|
||||
level: "info",
|
||||
action: "EXTERNAL_CHECK_PAYOUT_STATUS_SUCCESS",
|
||||
action: scopeTrafficAction("CHECK_PAYOUT_STATUS_SUCCESS", isPublicIp),
|
||||
surface: "mcp/check_payout_status",
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
@@ -1097,7 +1116,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
|
||||
void sendTrafficAlert({
|
||||
level: "info",
|
||||
action: "EXTERNAL_CHECK_PAYOUT_STATUS_SUCCESS",
|
||||
action: scopeTrafficAction("CHECK_PAYOUT_STATUS_SUCCESS", isPublicIp),
|
||||
surface: "mcp/check_payout_status",
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
@@ -1127,7 +1146,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
const parsed = CreateBountyRequestSchema.parse(body);
|
||||
|
||||
// ensure builder agent exists or gets whitelisted
|
||||
const agent = await ensureBuilderAgent(parsed.agent_id, requestContext);
|
||||
const agent = await ensureBuilderAgent(parsed.agent_id, requestContext, isPublicIp);
|
||||
if (!agent) {
|
||||
return NextResponse.json({ error: "Forbidden: Agent is not whitelisted" }, { status: 403 });
|
||||
}
|
||||
@@ -1166,7 +1185,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
|
||||
void sendTrafficAlert({
|
||||
level: "info",
|
||||
action: "EXTERNAL_CREATE_BOUNTY_SUCCESS",
|
||||
action: scopeTrafficAction("CREATE_BOUNTY_SUCCESS", isPublicIp),
|
||||
surface: "mcp/create_bounty",
|
||||
actorType: "AGENT",
|
||||
actorId: agent.agent_id,
|
||||
@@ -1199,7 +1218,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
default:
|
||||
void sendTrafficAlert({
|
||||
level: "warning",
|
||||
action: "EXTERNAL_MCP_TOOL_UNKNOWN",
|
||||
action: scopeTrafficAction("MCP_TOOL_UNKNOWN", isPublicIp),
|
||||
surface: `mcp/${tool}`,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
@@ -1231,7 +1250,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
|
||||
void sendTrafficAlert({
|
||||
level: "error",
|
||||
action: `EXTERNAL_${tool.toUpperCase()}_ERROR`,
|
||||
action: scopeTrafficAction(`${tool.toUpperCase()}_ERROR`, isPublicIp),
|
||||
surface: `mcp/${tool}`,
|
||||
actorType: "AGENT",
|
||||
actorId: actorInCatch.actorId,
|
||||
|
||||
@@ -51,6 +51,23 @@ function normalizeUserAgent(value: unknown) {
|
||||
return topToken.length > 48 ? `${topToken.slice(0, 45)}...` : topToken;
|
||||
}
|
||||
|
||||
function normalizePayloadSummary(value: unknown) {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : "unknown";
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
const AI_USER_AGENT_HINTS = [
|
||||
"gpt",
|
||||
"chatgpt",
|
||||
@@ -68,6 +85,100 @@ const AI_USER_AGENT_HINTS = [
|
||||
"copilot",
|
||||
];
|
||||
|
||||
type TrafficActorClass = "a2a" | "external_ai_agent" | "likely_ai_agent" | "other_external";
|
||||
|
||||
function resolveActorClass(
|
||||
action: string,
|
||||
actorType: string | null | undefined,
|
||||
actorId: string | null | undefined,
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
surface: string | undefined
|
||||
) {
|
||||
const normalizedSurface = (surface || "").toLowerCase();
|
||||
if (normalizedSurface.startsWith("mcp/")) {
|
||||
return "a2a";
|
||||
}
|
||||
|
||||
const normalizedActorId = (actorId || "").toLowerCase();
|
||||
if (actorType === "AGENT" || normalizedActorId.startsWith("agent:")) {
|
||||
if (action.startsWith("EXTERNAL_") && normalizedSurface.startsWith("mcp/")) {
|
||||
return "a2a";
|
||||
}
|
||||
return "external_ai_agent";
|
||||
}
|
||||
|
||||
if (isLikelyAIAgentActor(actorType, actorId, metadata)) {
|
||||
return "likely_ai_agent";
|
||||
}
|
||||
|
||||
return "other_external";
|
||||
}
|
||||
|
||||
function resolveMetadata(value: unknown): Record<string, unknown> | undefined {
|
||||
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveDisplayIp(event: { actorType: string | null; actorId: string | null; metadata: unknown }) {
|
||||
const metadata = resolveMetadata(event.metadata);
|
||||
const metadataIp = typeof metadata?.source_ip === "string" ? metadata.source_ip.trim() : undefined;
|
||||
if (metadataIp) {
|
||||
return metadataIp;
|
||||
}
|
||||
|
||||
if ((event.actorType || "").toUpperCase() === "USER" && event.actorId) {
|
||||
const marker = event.actorId.lastIndexOf(":");
|
||||
if (marker >= 0) {
|
||||
const actorIp = event.actorId.slice(marker + 1).trim();
|
||||
if (actorIp) {
|
||||
return actorIp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ((event.actorType || "").toUpperCase() === "SYSTEM") {
|
||||
return "system";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function resolveDisplayUserAgent(event: { actorType: string | null; metadata: unknown }) {
|
||||
const metadata = resolveMetadata(event.metadata);
|
||||
const metadataUa =
|
||||
typeof metadata?.user_agent === "string"
|
||||
? metadata.user_agent
|
||||
: typeof metadata?.userAgent === "string"
|
||||
? metadata.userAgent
|
||||
: undefined;
|
||||
|
||||
if (!metadataUa && (event.actorType || "").toUpperCase() === "SYSTEM") {
|
||||
return "system";
|
||||
}
|
||||
|
||||
return normalizeUserAgent(metadataUa);
|
||||
}
|
||||
|
||||
function resolveResponseStatus(event: { metadata: unknown }) {
|
||||
const metadata = resolveMetadata(event.metadata);
|
||||
const value = metadata?.response_status;
|
||||
|
||||
if (typeof value === "number") {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isLikelyAIAgentActor(
|
||||
actorType: string | null | undefined,
|
||||
actorId: string | null | undefined,
|
||||
@@ -432,6 +543,17 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const recentEvents = latestEvents.map((event) => {
|
||||
const metadata = asRecordJson(event.metadata);
|
||||
const actorClass =
|
||||
event.action.startsWith("EXTERNAL_") && !isInternalActor({ actorType: event.actorType, actorId: event.actorId })
|
||||
? resolveActorClass(
|
||||
event.action,
|
||||
event.actorType,
|
||||
event.actorId,
|
||||
metadata,
|
||||
typeof metadata?.surface === "string" ? metadata.surface : undefined
|
||||
)
|
||||
: "other_external";
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
action: event.action,
|
||||
@@ -444,6 +566,7 @@ export async function GET(request: NextRequest) {
|
||||
surface: metadata?.surface,
|
||||
level: metadata?.level,
|
||||
actorSource: classifyActorSource(event.actorType, event.actorId, metadata),
|
||||
actorClass,
|
||||
metadata,
|
||||
};
|
||||
});
|
||||
@@ -468,6 +591,7 @@ export async function GET(request: NextRequest) {
|
||||
const externalActorActivities: Map<string, {
|
||||
actor_id: string;
|
||||
events: number;
|
||||
actor_class: TrafficActorClass;
|
||||
latest_action: string;
|
||||
latest_surface: string;
|
||||
latest_source_ip: string;
|
||||
@@ -480,25 +604,75 @@ export async function GET(request: NextRequest) {
|
||||
latest_request_id: string;
|
||||
latest_created_at_ms: number;
|
||||
}> = new Map();
|
||||
const externalActorClassSummary = new Map<TrafficActorClass, { events: number; actors: Set<string> }>();
|
||||
|
||||
recentEvents.forEach((event) => {
|
||||
const actorId = event.actorId || "agent:unknown";
|
||||
const metadata = asRecordJson(event.metadata);
|
||||
const actorClass = resolveActorClass(
|
||||
event.action,
|
||||
event.actorType,
|
||||
event.actorId,
|
||||
metadata,
|
||||
typeof event.surface === "string" ? event.surface : undefined
|
||||
);
|
||||
if (event.action.startsWith("EXTERNAL_")) {
|
||||
const bucket = externalActorClassSummary.get(actorClass);
|
||||
if (!bucket) {
|
||||
externalActorClassSummary.set(actorClass, { events: 1, actors: new Set([actorId]) });
|
||||
} else {
|
||||
bucket.events += 1;
|
||||
bucket.actors.add(actorId);
|
||||
}
|
||||
}
|
||||
const normalizedSurface = normalizeSurface(event.surface);
|
||||
const normalizedIp = normalizeSourceIp(metadata?.source_ip);
|
||||
const normalizedUa = normalizeUserAgent(metadata?.user_agent);
|
||||
const isExternalAgent = event.action.startsWith("EXTERNAL_") &&
|
||||
event.actorType === "AGENT" &&
|
||||
!isInternalActor({ actorType: event.actorType, actorId: event.actorId });
|
||||
const isTrackedExternalActor =
|
||||
event.action.startsWith("EXTERNAL_") &&
|
||||
!isInternalActor({ actorType: event.actorType, actorId: event.actorId }) &&
|
||||
(actorClass === "a2a" || actorClass === "external_ai_agent" || actorClass === "likely_ai_agent");
|
||||
|
||||
if (!isExternalAgent) {
|
||||
if (!isTrackedExternalActor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventAt = event.createdAt.getTime();
|
||||
const responseStatus = typeof metadata?.response_status === "number" ? metadata.response_status : null;
|
||||
const errorName = typeof metadata?.error_name === "string" ? metadata.error_name : "";
|
||||
const errorMessage = typeof metadata?.error_message === "string" ? metadata.error_message : "";
|
||||
const responseStatus =
|
||||
typeof metadata?.response_status === "number"
|
||||
? metadata.response_status
|
||||
: typeof metadata?.response_status === "string"
|
||||
? Number.parseInt(metadata.response_status, 10)
|
||||
: null;
|
||||
|
||||
const fallbackErrorName =
|
||||
event.action === "EXTERNAL_MCP_AUTH_MISSING"
|
||||
? "AUTH_MISSING"
|
||||
: event.action === "EXTERNAL_MCP_AUTH_FORBIDDEN"
|
||||
? "AUTH_FORBIDDEN"
|
||||
: event.action.includes("FAIL")
|
||||
? "INTERNAL_ERROR"
|
||||
: ""
|
||||
;
|
||||
|
||||
const fallbackErrorMessage =
|
||||
typeof metadata?.auth_issue === "string"
|
||||
? metadata.auth_issue
|
||||
: typeof event.reason === "string"
|
||||
? event.reason
|
||||
: typeof metadata?.response_summary === "string"
|
||||
? metadata.response_summary
|
||||
: ""
|
||||
;
|
||||
|
||||
const errorName =
|
||||
typeof metadata?.error_name === "string" && metadata.error_name.length > 0
|
||||
? metadata.error_name
|
||||
: fallbackErrorName;
|
||||
const errorMessage =
|
||||
typeof metadata?.error_message === "string" && metadata.error_message.length > 0
|
||||
? metadata.error_message
|
||||
: fallbackErrorMessage;
|
||||
const taskId = typeof metadata?.task_id === "string" ? metadata.task_id : (event.entityId || "-");
|
||||
const responseSummary =
|
||||
typeof metadata?.response_summary === "string" ? metadata.response_summary : "unknown";
|
||||
@@ -513,6 +687,7 @@ export async function GET(request: NextRequest) {
|
||||
externalActorActivities.set(actorId, {
|
||||
actor_id: actorId,
|
||||
events: 1,
|
||||
actor_class: actorClass,
|
||||
latest_action: event.action,
|
||||
latest_surface: normalizedSurface,
|
||||
latest_source_ip: normalizedIp,
|
||||
@@ -522,7 +697,7 @@ export async function GET(request: NextRequest) {
|
||||
latest_response_summary: responseSummary,
|
||||
latest_reason: event.reason || "unknown",
|
||||
latest_payload_summary:
|
||||
typeof metadata?.payload_summary === "string" ? metadata.payload_summary : "unknown",
|
||||
normalizePayloadSummary(metadata?.payload_summary),
|
||||
latest_request_id:
|
||||
typeof metadata?.request_id === "string" ? metadata.request_id : "unknown",
|
||||
latest_created_at_ms: eventAt,
|
||||
@@ -540,7 +715,7 @@ export async function GET(request: NextRequest) {
|
||||
existingActorActivity.latest_response_summary = responseSummary;
|
||||
existingActorActivity.latest_reason = event.reason || "unknown";
|
||||
existingActorActivity.latest_payload_summary =
|
||||
typeof metadata?.payload_summary === "string" ? metadata.payload_summary : "unknown";
|
||||
normalizePayloadSummary(metadata?.payload_summary);
|
||||
existingActorActivity.latest_request_id =
|
||||
typeof metadata?.request_id === "string" ? metadata.request_id : "unknown";
|
||||
existingActorActivity.latest_created_at_ms = eventAt;
|
||||
@@ -568,17 +743,30 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
});
|
||||
|
||||
const recentExternalEvents = recentEvents.filter((event) =>
|
||||
event.action.startsWith("EXTERNAL_") &&
|
||||
!isInternalActor({
|
||||
actorType: event.actorType,
|
||||
actorId: event.actorId,
|
||||
})
|
||||
);
|
||||
const recentExternalEvents = recentEvents
|
||||
.filter(
|
||||
(event) =>
|
||||
event.action.startsWith("EXTERNAL_") &&
|
||||
!isInternalActor({
|
||||
actorType: event.actorType,
|
||||
actorId: event.actorId,
|
||||
})
|
||||
)
|
||||
.map((event) => ({
|
||||
...event,
|
||||
source_ip: resolveDisplayIp(event),
|
||||
user_agent: resolveDisplayUserAgent(event),
|
||||
response_status: resolveResponseStatus(event),
|
||||
}));
|
||||
|
||||
const recentInternalEvents = recentEvents.filter(
|
||||
(event) => !event.action.startsWith("EXTERNAL_")
|
||||
);
|
||||
const recentInternalEvents = recentEvents
|
||||
.filter((event) => !event.action.startsWith("EXTERNAL_"))
|
||||
.map((event) => ({
|
||||
...event,
|
||||
source_ip: resolveDisplayIp(event),
|
||||
user_agent: resolveDisplayUserAgent(event),
|
||||
response_status: resolveResponseStatus(event),
|
||||
}));
|
||||
|
||||
const externalSurfaceSummary = Array.from(externalSourceSurfaceMap.entries())
|
||||
.map(([surface, bucket]) => ({
|
||||
@@ -629,6 +817,14 @@ export async function GET(request: NextRequest) {
|
||||
})
|
||||
.slice(0, 40);
|
||||
|
||||
const externalActorClassSummaryRows = Array.from(externalActorClassSummary.entries())
|
||||
.map(([actorClass, bucket]) => ({
|
||||
actor_class: actorClass,
|
||||
events: bucket.events,
|
||||
actors: bucket.actors.size,
|
||||
}))
|
||||
.sort((a, b) => b.events - a.events);
|
||||
|
||||
return NextResponse.json({
|
||||
period_minutes: minutes,
|
||||
total_events: totalRows,
|
||||
@@ -645,6 +841,7 @@ export async function GET(request: NextRequest) {
|
||||
external_source_ip_summary: externalSourceIpSummary,
|
||||
external_user_agent_summary: externalUserAgentSummary,
|
||||
external_response_status_summary: externalResponseStatusSummary,
|
||||
external_actor_class_summary: externalActorClassSummaryRows,
|
||||
external_actor_activities: externalActorActivityRows,
|
||||
external_error_rows: externalErrorRowsSorted,
|
||||
recent_external_events: recentExternalEvents,
|
||||
|
||||
82
apps/web/src/app/login/page.tsx
Normal file
82
apps/web/src/app/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -119,6 +119,9 @@ export default async function Home() {
|
||||
<Link href="/traffic" className="inline-flex items-center gap-2 text-emerald-400 hover:text-emerald-300">
|
||||
查看外部 AI 導流監控 →
|
||||
</Link>
|
||||
<Link href="/admin/traffic" className="inline-flex items-center gap-2 text-amber-400 hover:text-amber-300 mt-2 block">
|
||||
管理後台:流量監控總覽 →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { headers } from "next/headers";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { isIP } from "node:net";
|
||||
|
||||
@@ -77,7 +78,15 @@ function isInternalActor(input: { actorType: string | null | undefined; actorId:
|
||||
return isInternalActorId(input.actorId);
|
||||
}
|
||||
|
||||
function isAuthorizedToken(token: string | undefined, tokenHeader: string | undefined) {
|
||||
function isAuthorizedToken(
|
||||
token: string | undefined,
|
||||
tokenHeader: string | undefined,
|
||||
isAdmin = false,
|
||||
) {
|
||||
if (isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!token) return true;
|
||||
return tokenHeader === token;
|
||||
}
|
||||
@@ -91,9 +100,95 @@ function explainAction(action: string) {
|
||||
return EVENT_LABELS[action] || action;
|
||||
}
|
||||
|
||||
type TrafficActorClass = "a2a" | "external_ai_agent" | "likely_ai_agent" | "other_external";
|
||||
|
||||
const AI_USER_AGENT_HINTS = [
|
||||
"gpt",
|
||||
"chatgpt",
|
||||
"openai",
|
||||
"anthropic",
|
||||
"claude",
|
||||
"perplexity",
|
||||
"llm",
|
||||
"mcp",
|
||||
"autogpt",
|
||||
"agent",
|
||||
"assistant",
|
||||
"gemini",
|
||||
"cursor",
|
||||
"copilot",
|
||||
];
|
||||
|
||||
function isLikelyAIAgentActor(
|
||||
actorType: string | null | undefined,
|
||||
actorId: string | null | undefined,
|
||||
metadata: Record<string, unknown> | undefined
|
||||
) {
|
||||
if (actorType === "AGENT") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedActor = (actorId || "").toLowerCase();
|
||||
if (normalizedActor.startsWith("agent:")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const userAgent = String(metadata?.user_agent || "").toLowerCase();
|
||||
if (AI_USER_AGENT_HINTS.some((token) => userAgent.includes(token))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const requestHeaders = asRecordJson(metadata?.request_actor_headers);
|
||||
if (!requestHeaders) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const headerText = Object.values(requestHeaders)
|
||||
.filter((item): item is string => typeof item === "string")
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
|
||||
return AI_USER_AGENT_HINTS.some((token) => headerText.includes(token));
|
||||
}
|
||||
|
||||
function resolveActorClass(
|
||||
action: string,
|
||||
actorType: string | null | undefined,
|
||||
actorId: string | null | undefined,
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
surface: string | undefined
|
||||
) {
|
||||
const normalizedSurface = (surface || "").toLowerCase();
|
||||
if (normalizedSurface.startsWith("mcp/")) {
|
||||
return "a2a";
|
||||
}
|
||||
|
||||
const normalizedActorId = (actorId || "").toLowerCase();
|
||||
if (actorType === "AGENT" || normalizedActorId.startsWith("agent:")) {
|
||||
if (action.startsWith("EXTERNAL_") && normalizedSurface.startsWith("mcp/")) {
|
||||
return "a2a";
|
||||
}
|
||||
return "external_ai_agent";
|
||||
}
|
||||
|
||||
if (isLikelyAIAgentActor(actorType, actorId, metadata)) {
|
||||
return "likely_ai_agent";
|
||||
}
|
||||
|
||||
return action.startsWith("EXTERNAL_") ? "other_external" : "other_external";
|
||||
}
|
||||
|
||||
function actorClassLabel(actorClass: TrafficActorClass) {
|
||||
if (actorClass === "a2a") return "A2A (MCP)";
|
||||
if (actorClass === "external_ai_agent") return "外部 AI Agent";
|
||||
if (actorClass === "likely_ai_agent") return "疑似 AI 流量";
|
||||
return "其他外部流量";
|
||||
}
|
||||
|
||||
type ExternalActorActivity = {
|
||||
actorId: string;
|
||||
events: number;
|
||||
actorClass: TrafficActorClass;
|
||||
latestAction: string;
|
||||
latestSurface: string;
|
||||
latestSourceIp: string;
|
||||
@@ -268,6 +363,13 @@ async function getTrafficSummary(minutes: number) {
|
||||
|
||||
const recentEvents = latestEvents.map((event) => {
|
||||
const metadata = asRecordJson(event.metadata);
|
||||
const actorClass = resolveActorClass(
|
||||
event.action,
|
||||
event.actorType,
|
||||
event.actorId,
|
||||
metadata,
|
||||
typeof metadata?.surface === "string" ? metadata.surface : undefined
|
||||
);
|
||||
return {
|
||||
...event,
|
||||
surface: metadata?.surface,
|
||||
@@ -276,6 +378,7 @@ async function getTrafficSummary(minutes: number) {
|
||||
response_summary: typeof metadata?.response_summary === "string" ? metadata.response_summary : "unknown",
|
||||
payload_summary: typeof metadata?.payload_summary === "string" ? metadata.payload_summary : "unknown",
|
||||
request_id: typeof metadata?.request_id === "string" ? metadata.request_id : "n/a",
|
||||
actor_class: actorClass,
|
||||
metadata,
|
||||
};
|
||||
});
|
||||
@@ -315,6 +418,15 @@ async function getTrafficSummary(minutes: number) {
|
||||
.filter((event) => event.action.includes("ERROR"))
|
||||
.map((event) => event.action);
|
||||
|
||||
const externalActorClassMap = new Map<TrafficActorClass, number>();
|
||||
for (const event of recentEvents.filter((event) => event.action.startsWith("EXTERNAL_"))) {
|
||||
const actorClass = (event as { actor_class?: TrafficActorClass }).actor_class || "other_external";
|
||||
externalActorClassMap.set(actorClass, (externalActorClassMap.get(actorClass) || 0) + 1);
|
||||
}
|
||||
const externalActorClassSummary = Array.from(externalActorClassMap.entries())
|
||||
.map(([actor_class, events]) => ({ actor_class, events }))
|
||||
.sort((a, b) => b.events - a.events);
|
||||
|
||||
const demandHealthLabel = demandSupply.openTaskCount > 0 ? "有可接需求" : "無可接需求";
|
||||
const demandHealthTone = demandSupply.openTaskCount > 0 ? "text-emerald-300" : "text-amber-300";
|
||||
|
||||
@@ -325,11 +437,16 @@ async function getTrafficSummary(minutes: number) {
|
||||
}
|
||||
|
||||
const actorId = event.actorId || "agent:unknown";
|
||||
const actorClass = (event as { actor_class?: TrafficActorClass }).actor_class || "other_external";
|
||||
const isInternal = isInternalActor({
|
||||
actorType: event.actorType,
|
||||
actorId: event.actorId,
|
||||
});
|
||||
if (isInternal || event.actorType !== "AGENT") {
|
||||
const isTrackedExternal =
|
||||
!isInternal &&
|
||||
(actorClass === "a2a" || actorClass === "external_ai_agent" || actorClass === "likely_ai_agent");
|
||||
|
||||
if (!isTrackedExternal) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -357,6 +474,7 @@ async function getTrafficSummary(minutes: number) {
|
||||
externalActorActivityMap.set(actorId, {
|
||||
actorId,
|
||||
events: 1,
|
||||
actorClass,
|
||||
latestAction: event.action,
|
||||
latestSurface: String(event.surface || "unknown"),
|
||||
latestSourceIp: normalizedIp,
|
||||
@@ -438,6 +556,7 @@ async function getTrafficSummary(minutes: number) {
|
||||
recentInternalEvents: recentEvents.filter((event) => !event.action.startsWith("EXTERNAL_")),
|
||||
conversionSummary,
|
||||
conversionRates,
|
||||
externalActorClassSummary,
|
||||
externalActorActivities,
|
||||
externalErrors,
|
||||
demandSupply,
|
||||
@@ -503,8 +622,11 @@ export default async function TrafficDashboard({
|
||||
const resolved = await searchParams;
|
||||
const token = resolved?.token;
|
||||
const minutes = Math.max(parseInt(resolved?.minutes || "1440", 10) || 5, 5);
|
||||
const requestHeaders = await headers();
|
||||
const requestTokenHeader = requestHeaders.get("x-traffic-token");
|
||||
const isAdmin = requestHeaders.get("x-admin-authenticated") === "1";
|
||||
|
||||
if (!isAuthorizedToken(MONITOR_TOKEN, token)) {
|
||||
if (!isAuthorizedToken(MONITOR_TOKEN, (token ?? requestTokenHeader) || undefined, isAdmin)) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 text-gray-100 p-8 font-sans">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
@@ -666,6 +788,22 @@ export default async function TrafficDashboard({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">外部流量來源分類(近 120 筆事件)</h2>
|
||||
<div className="space-y-2">
|
||||
{summary.externalActorClassSummary.length === 0 ? (
|
||||
<p className="text-gray-500">目前區間內尚無外部分類資料。</p>
|
||||
) : (
|
||||
summary.externalActorClassSummary.map((item) => (
|
||||
<div key={item.actor_class} className="flex justify-between text-sm">
|
||||
<span className="text-gray-300">{actorClassLabel(item.actor_class)}</span>
|
||||
<span className="text-emerald-300">{item.events}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">外部來源 Actor 前 20</h2>
|
||||
<div className="space-y-2 max-h-80 overflow-auto">
|
||||
@@ -704,6 +842,7 @@ export default async function TrafficDashboard({
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-gray-400 border-b border-gray-700">
|
||||
<th className="text-left py-2">來源類型</th>
|
||||
<th className="text-left py-2">Actor</th>
|
||||
<th className="text-left py-2">事件</th>
|
||||
<th className="text-left py-2">最新行為</th>
|
||||
@@ -717,13 +856,14 @@ export default async function TrafficDashboard({
|
||||
<tbody>
|
||||
{summary.externalActorActivities.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="text-gray-500 py-3">
|
||||
<td colSpan={9} className="text-gray-500 py-3">
|
||||
尚無可追蹤的外部 AGENT 行為,可能目前仍未有 AGENT 類型入口流量。
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
summary.externalActorActivities.map((actor) => (
|
||||
<tr key={actor.actorId} className="border-b border-gray-800">
|
||||
<td className="py-2 text-gray-300">{actorClassLabel(actor.actorClass)}</td>
|
||||
<td className="py-2 text-gray-300">{actor.actorId}</td>
|
||||
<td className="py-2 text-emerald-300">{actor.events}</td>
|
||||
<td className="py-2">
|
||||
@@ -800,7 +940,7 @@ export default async function TrafficDashboard({
|
||||
<div key={event.id} className="border-b border-gray-800 py-2 text-sm">
|
||||
<div className="font-mono text-emerald-300">{event.action}</div>
|
||||
<div className="text-gray-400">
|
||||
actor={event.actorType}:{event.actorId || "unknown"} | entity={event.entityType}/{event.entityId} | surface={String(event.surface || "-")} | {ts}
|
||||
actor={event.actorType}:{event.actorId || "unknown"} | 類別={actorClassLabel((event as { actor_class?: TrafficActorClass }).actor_class || "other_external")} | entity={event.entityType}/{event.entityId} | surface={String(event.surface || "-")} | {ts}
|
||||
</div>
|
||||
<div className="text-gray-500 text-xs mt-1">
|
||||
response={event.response_status ?? "n/a"} / summary={event.response_summary}
|
||||
|
||||
97
apps/web/src/lib/admin-auth.ts
Normal file
97
apps/web/src/lib/admin-auth.ts
Normal 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;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export type TrafficAlertEvent = {
|
||||
surface: string;
|
||||
actorType: "SYSTEM" | "AGENT" | "USER";
|
||||
actorId: string;
|
||||
actorClass?: "a2a" | "external_ai_agent" | "likely_ai_agent" | "other_external";
|
||||
taskId?: string;
|
||||
message: string;
|
||||
sourceIp?: string;
|
||||
@@ -58,6 +59,76 @@ const TELEGRAM_IP_FAMILY = Number.parseInt(
|
||||
process.env.TELEGRAM_IP_FAMILY?.trim() || "4",
|
||||
10
|
||||
) as 4 | 6;
|
||||
const TELEGRAM_NOTIFY_EXTERNAL_ONLY =
|
||||
process.env.TELEGRAM_NOTIFY_EXTERNAL_ONLY?.trim().toLowerCase() === "true";
|
||||
const TELEGRAM_NOTIFY_SYSTEM_WARN_INTERNAL_SUPPRESS =
|
||||
process.env.TELEGRAM_NOTIFY_SYSTEM_WARN_INTERNAL_SUPPRESS?.trim().toLowerCase() !== "false";
|
||||
|
||||
const AI_USER_AGENT_HINTS = [
|
||||
"gpt",
|
||||
"chatgpt",
|
||||
"openai",
|
||||
"anthropic",
|
||||
"claude",
|
||||
"perplexity",
|
||||
"llm",
|
||||
"mcp",
|
||||
"autogpt",
|
||||
"agent",
|
||||
"assistant",
|
||||
"gemini",
|
||||
"cursor",
|
||||
"copilot",
|
||||
];
|
||||
|
||||
function normalizeTrafficMetadata(event: TrafficAlertEvent): Record<string, unknown> {
|
||||
if (!event.metadata || typeof event.metadata !== "object") {
|
||||
return {};
|
||||
}
|
||||
return event.metadata as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function resolveActorClass(event: TrafficAlertEvent) {
|
||||
if (event.actorClass) {
|
||||
return event.actorClass;
|
||||
}
|
||||
|
||||
const normalizedSurface = event.surface.trim().toLowerCase();
|
||||
if (normalizedSurface.startsWith("mcp/")) {
|
||||
return "a2a";
|
||||
}
|
||||
|
||||
if (event.actorType === "AGENT" || event.actorId.toLowerCase().startsWith("agent:")) {
|
||||
return "external_ai_agent";
|
||||
}
|
||||
|
||||
const metadata = normalizeTrafficMetadata(event);
|
||||
const userAgent = String(metadata.user_agent || "").toLowerCase();
|
||||
if (AI_USER_AGENT_HINTS.some((token) => userAgent.includes(token))) {
|
||||
return "likely_ai_agent";
|
||||
}
|
||||
|
||||
const headers = metadata.request_actor_headers;
|
||||
if (typeof headers === "object" && headers !== null) {
|
||||
const headerText = Object.values(headers)
|
||||
.filter((item): item is string => typeof item === "string")
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
|
||||
if (AI_USER_AGENT_HINTS.some((token) => headerText.includes(token))) {
|
||||
return "likely_ai_agent";
|
||||
}
|
||||
}
|
||||
|
||||
return event.action.startsWith("EXTERNAL_") ? "other_external" : "other_external";
|
||||
}
|
||||
|
||||
function formatActorClass(actorClass: ReturnType<typeof resolveActorClass>) {
|
||||
if (actorClass === "a2a") return "A2A";
|
||||
if (actorClass === "external_ai_agent") return "外部 AI Agent";
|
||||
if (actorClass === "likely_ai_agent") return "疑似 AI 流量";
|
||||
return "其他外部流量";
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
@@ -245,17 +316,24 @@ function extractIpFromActor(actorType: TrafficAlertEvent["actorType"], actorId:
|
||||
|
||||
function resolveDisplaySourceIp(event: TrafficAlertEvent) {
|
||||
const metadataIp = typeof event.metadata?.source_ip === "string" ? event.metadata.source_ip : undefined;
|
||||
return event.sourceIp || metadataIp || extractIpFromActor(event.actorType, event.actorId);
|
||||
return (
|
||||
event.sourceIp ||
|
||||
metadataIp ||
|
||||
extractIpFromActor(event.actorType, event.actorId) ||
|
||||
(event.actorType === "SYSTEM" ? "system" : undefined)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveDisplayUserAgent(event: TrafficAlertEvent) {
|
||||
return (
|
||||
event.userAgent ||
|
||||
(typeof event.metadata?.user_agent === "string" ? event.metadata.user_agent : undefined)
|
||||
(typeof event.metadata?.user_agent === "string" ? event.metadata.user_agent : undefined) ||
|
||||
(event.actorType === "SYSTEM" ? "system" : undefined)
|
||||
);
|
||||
}
|
||||
|
||||
function buildTelegramMessage(event: TrafficAlertEvent) {
|
||||
const actorClass = resolveActorClass(event);
|
||||
return (
|
||||
`<b>VibeWork 流量告警</b>` +
|
||||
`\n- 平台: <code>agent-bounty-protocol</code>` +
|
||||
@@ -263,6 +341,7 @@ function buildTelegramMessage(event: TrafficAlertEvent) {
|
||||
`\n- 行為: <code>${escapeHtml(event.action)}</code>` +
|
||||
`\n- 通道: <code>${escapeHtml(event.surface)}</code>` +
|
||||
`\n- Actor: <code>${escapeHtml(`${event.actorType}/${event.actorId}`)}</code>` +
|
||||
`\n- 類別: <code>${escapeHtml(formatActorClass(actorClass))}</code>` +
|
||||
`\n- Source IP: <code>${escapeHtml(resolveDisplaySourceIp(event) || "n/a")}</code>` +
|
||||
`\n- User-Agent: <code>${escapeHtml(resolveDisplayUserAgent(event) || "n/a")}</code>` +
|
||||
`\n- 回應: <code>${escapeHtml(typeof event.metadata?.response_status === "number" ? event.metadata.response_status : "n/a")}</code>` +
|
||||
@@ -272,6 +351,30 @@ function buildTelegramMessage(event: TrafficAlertEvent) {
|
||||
);
|
||||
}
|
||||
|
||||
function isExternalAction(action: string) {
|
||||
return action.startsWith("EXTERNAL_");
|
||||
}
|
||||
|
||||
function shouldNotifyViaTelegram(event: TrafficAlertEvent) {
|
||||
if (!TELEGRAM_NOTIFY_EXTERNAL_ONLY) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isExternalAction(event.action)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.level === "error") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TELEGRAM_NOTIFY_SYSTEM_WARN_INTERNAL_SUPPRESS && event.level === "warning") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function sendViaHttps(url: string, body: Record<string, unknown>) {
|
||||
return new Promise<{ messageId?: string }>((resolve, reject) => {
|
||||
try {
|
||||
@@ -389,7 +492,16 @@ function resolveEntityFromTrafficEvent(event: TrafficAlertEvent) {
|
||||
}
|
||||
|
||||
async function writeTrafficAuditEvent(event: TrafficAlertEvent) {
|
||||
const actorClass = resolveActorClass(event);
|
||||
const { entityType, entityId } = resolveEntityFromTrafficEvent(event);
|
||||
const sourceIp =
|
||||
event.sourceIp ||
|
||||
(typeof event.metadata?.source_ip === "string" ? event.metadata.source_ip : undefined) ||
|
||||
(event.actorType === "SYSTEM" ? "system" : undefined);
|
||||
const userAgent =
|
||||
event.userAgent ||
|
||||
(typeof event.metadata?.user_agent === "string" ? event.metadata.user_agent : undefined) ||
|
||||
(event.actorType === "SYSTEM" ? "system" : undefined);
|
||||
|
||||
try {
|
||||
await prisma.auditEvent.create({
|
||||
@@ -404,8 +516,11 @@ async function writeTrafficAuditEvent(event: TrafficAlertEvent) {
|
||||
reason: event.message,
|
||||
metadata: {
|
||||
...event.metadata,
|
||||
actor_class: actorClass,
|
||||
level: event.level,
|
||||
surface: event.surface,
|
||||
source_ip: sourceIp,
|
||||
user_agent: userAgent,
|
||||
source: "traffic-alert",
|
||||
},
|
||||
},
|
||||
@@ -416,20 +531,23 @@ async function writeTrafficAuditEvent(event: TrafficAlertEvent) {
|
||||
}
|
||||
|
||||
export async function sendTrafficAlert(event: TrafficAlertEvent): Promise<void> {
|
||||
void writeTrafficAuditEvent(event);
|
||||
const actorClass = resolveActorClass(event);
|
||||
const enrichedEvent: TrafficAlertEvent = { ...event, actorClass };
|
||||
void writeTrafficAuditEvent(enrichedEvent);
|
||||
const eventSourceIp =
|
||||
event.sourceIp ??
|
||||
(typeof event.metadata?.source_ip === "string" ? event.metadata.source_ip : undefined);
|
||||
enrichedEvent.sourceIp ??
|
||||
(typeof enrichedEvent.metadata?.source_ip === "string" ? enrichedEvent.metadata.source_ip : undefined);
|
||||
const eventUserAgent =
|
||||
event.userAgent ??
|
||||
(typeof event.metadata?.user_agent === "string" ? event.metadata.user_agent : undefined);
|
||||
enrichedEvent.userAgent ??
|
||||
(typeof enrichedEvent.metadata?.user_agent === "string" ? enrichedEvent.metadata.user_agent : undefined);
|
||||
|
||||
const payload = {
|
||||
platform: "agent-bounty-protocol",
|
||||
created_at: new Date().toISOString(),
|
||||
actor_class: actorClass,
|
||||
source_ip: eventSourceIp,
|
||||
user_agent: eventUserAgent,
|
||||
...event,
|
||||
...enrichedEvent,
|
||||
};
|
||||
|
||||
const resolvedTelegramChatId = await resolveTelegramChatId();
|
||||
@@ -461,7 +579,8 @@ export async function sendTrafficAlert(event: TrafficAlertEvent): Promise<void>
|
||||
},
|
||||
},
|
||||
TELEGRAM_BOT_TOKEN &&
|
||||
resolvedTelegramChatId && {
|
||||
resolvedTelegramChatId &&
|
||||
shouldNotifyViaTelegram(event) && {
|
||||
kind: "telegram",
|
||||
url: `https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage`,
|
||||
init: {
|
||||
|
||||
@@ -17,6 +17,11 @@ type FunnelSummary = {
|
||||
externalSubmittingActors: number;
|
||||
externalOnlyOpenActors: number;
|
||||
topOpenOnlyActors: Array<{ actorId: string; opens: number }>;
|
||||
judgeFailureReasons: Array<{
|
||||
reason: string;
|
||||
count: number;
|
||||
examples: string[];
|
||||
}>;
|
||||
payoutCaptured: number;
|
||||
payoutReleased: number;
|
||||
periodMinutes: number;
|
||||
@@ -109,7 +114,7 @@ async function fetchFunnelSummary(minutes: number): Promise<FunnelSummary> {
|
||||
createdAt: { gte: since },
|
||||
action: "JUDGE_COMPLETE",
|
||||
},
|
||||
select: { metadata: true },
|
||||
select: { metadata: true, entityId: true },
|
||||
}),
|
||||
prisma.task.findMany({
|
||||
where: {
|
||||
@@ -177,15 +182,58 @@ async function fetchFunnelSummary(minutes: number): Promise<FunnelSummary> {
|
||||
const mcpAuthMissingEvents = actionSummary["EXTERNAL_MCP_AUTH_MISSING"] || 0;
|
||||
const mcpAuthForbiddenEvents = actionSummary["EXTERNAL_MCP_AUTH_FORBIDDEN"] || 0;
|
||||
|
||||
const judgePassEvents = judgeRows.filter((row) => {
|
||||
const judgePassRows = judgeRows.filter((row) => {
|
||||
const metadata = asRecordJson(row.metadata);
|
||||
return normalizedJudgeResult(metadata?.overall_result) === "pass";
|
||||
}).length;
|
||||
|
||||
const judgeFailEvents = judgeRows.filter((row) => {
|
||||
});
|
||||
const judgeFailRows = judgeRows.filter((row) => {
|
||||
const metadata = asRecordJson(row.metadata);
|
||||
return normalizedJudgeResult(metadata?.overall_result) === "fail";
|
||||
}).length;
|
||||
});
|
||||
const judgePassEvents = judgePassRows.length;
|
||||
const judgeFailEvents = judgeFailRows.length;
|
||||
|
||||
const judgeFailureReasonMap = new Map<string, { count: number; examples: string[] }>();
|
||||
judgeFailRows.forEach((row) => {
|
||||
const metadata = asRecordJson(row.metadata);
|
||||
const reasonRaw =
|
||||
typeof metadata?.error_classification === "string" && metadata.error_classification.length > 0
|
||||
? metadata.error_classification
|
||||
: typeof metadata?.error_classification_legacy === "string" &&
|
||||
metadata.error_classification_legacy.length > 0
|
||||
? metadata.error_classification_legacy
|
||||
: "UNKNOWN";
|
||||
|
||||
const reason = reasonRaw.toUpperCase();
|
||||
const existing = judgeFailureReasonMap.get(reason);
|
||||
if (!existing) {
|
||||
judgeFailureReasonMap.set(reason, {
|
||||
count: 1,
|
||||
examples: row.entityId ? [row.entityId] : [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
existing.count += 1;
|
||||
if (row.entityId && existing.examples.length < 3) {
|
||||
existing.examples.push(row.entityId);
|
||||
}
|
||||
});
|
||||
|
||||
const judgeFailureReasons = Array.from(judgeFailureReasonMap.entries())
|
||||
.map(([reason, value]) => ({
|
||||
reason,
|
||||
count: value.count,
|
||||
examples: value.examples,
|
||||
}))
|
||||
.sort((left, right) => {
|
||||
if (right.count !== left.count) {
|
||||
return right.count - left.count;
|
||||
}
|
||||
|
||||
return left.reason.localeCompare(right.reason);
|
||||
})
|
||||
.slice(0, 5);
|
||||
|
||||
const actorMap = new Map<
|
||||
string,
|
||||
@@ -243,6 +291,7 @@ async function fetchFunnelSummary(minutes: number): Promise<FunnelSummary> {
|
||||
externalSubmittingActors,
|
||||
externalOnlyOpenActors,
|
||||
topOpenOnlyActors,
|
||||
judgeFailureReasons,
|
||||
payoutCaptured,
|
||||
payoutReleased,
|
||||
periodMinutes: minutes,
|
||||
@@ -266,6 +315,7 @@ function buildAlertMessage(rule: string, summary: FunnelSummary) {
|
||||
externalSubmittingActors,
|
||||
externalOnlyOpenActors,
|
||||
topOpenOnlyActors,
|
||||
judgeFailureReasons,
|
||||
} = summary;
|
||||
|
||||
const authBarrierEvents = mcpAuthMissingEvents + mcpAuthForbiddenEvents;
|
||||
@@ -290,7 +340,21 @@ function buildAlertMessage(rule: string, summary: FunnelSummary) {
|
||||
case "EXTERNAL_FUNNEL_SUBMIT_STALL":
|
||||
return `外部已有 ${claimEvents} 筆接案,但近期 ${periodMinutes} 分鐘無任何提交(EXTERNAL_SUBMIT_SOLUTION_SUCCESS = ${submitEvents})。請先加速回傳格式與驗收測試規格。`;
|
||||
case "EXTERNAL_FUNNEL_PASS_STALL":
|
||||
return `外部已提交 ${submitEvents} 次但尚無 PASS(JUDGE_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} 次但尚無 PASS(JUDGE_RESULT PASS = ${judgePassEvents})。${failureHint}` +
|
||||
`${failureSampleTasks.length > 0 ? `失敗任務樣本: ${failureSampleTasks.join(", ")}。` : ""}` +
|
||||
`請先檢查 task acceptance_criteria 與測試欄位是否可自動驗證。`;
|
||||
case "EXTERNAL_FUNNEL_PAYOUT_STALL":
|
||||
return `有 PASS 但未收款(payout CAPTURE 成功 = ${payoutCaptured})。請確認支付授權、Stripe key 與 capture 任務是否正常。`;
|
||||
case "EXTERNAL_FUNNEL_OPEN_COLD_STANDBY":
|
||||
@@ -384,8 +448,12 @@ export async function evaluateExternalFunnelHealth(input: MonitorInput): Promise
|
||||
surface: input.surface,
|
||||
actorType: "SYSTEM",
|
||||
actorId: "traffic-monitor",
|
||||
sourceIp: "system",
|
||||
userAgent: "traffic-monitor",
|
||||
message: rule.message,
|
||||
metadata: {
|
||||
source_ip: "system",
|
||||
user_agent: "traffic-monitor",
|
||||
discovery_events: summary.discoveryEvents,
|
||||
claim_events: summary.claimEvents,
|
||||
submit_events: summary.submitEvents,
|
||||
@@ -399,6 +467,7 @@ export async function evaluateExternalFunnelHealth(input: MonitorInput): Promise
|
||||
external_only_open_actors: summary.externalOnlyOpenActors,
|
||||
mcp_auth_missing_events: summary.mcpAuthMissingEvents,
|
||||
mcp_auth_forbidden_events: summary.mcpAuthForbiddenEvents,
|
||||
judge_failure_reasons: summary.judgeFailureReasons,
|
||||
payout_captured: summary.payoutCaptured,
|
||||
payout_released: summary.payoutReleased,
|
||||
period_minutes: summary.periodMinutes,
|
||||
|
||||
@@ -1,12 +1,31 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import {
|
||||
adminUnauthorizedResponse,
|
||||
attachAdminHeaders,
|
||||
isAdminRequestAuthorized,
|
||||
} from "@/lib/admin-auth";
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const url = request.nextUrl;
|
||||
|
||||
const isAdminPath = url.pathname.startsWith("/admin");
|
||||
|
||||
if (isAdminPath) {
|
||||
if (!isAdminRequestAuthorized(request)) {
|
||||
return adminUnauthorizedResponse();
|
||||
}
|
||||
|
||||
const headers = attachAdminHeaders(request);
|
||||
return NextResponse.next({
|
||||
request: {
|
||||
headers,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Check if there is a referral parameter ?ref=
|
||||
const ref = url.searchParams.get('ref');
|
||||
|
||||
|
||||
if (ref) {
|
||||
const response = NextResponse.redirect(url.pathname);
|
||||
// Set cookie for 30 days
|
||||
@@ -25,13 +44,6 @@ export function middleware(request: NextRequest) {
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
/*
|
||||
* Match all request paths except for the ones starting with:
|
||||
* - api (API routes)
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimization files)
|
||||
* - favicon.ico (favicon file)
|
||||
*/
|
||||
'/((?!api|_next/static|_next/image|favicon.ico).*)',
|
||||
'/((?!_next/static|_next/image|favicon.ico).*)',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -54,6 +54,8 @@ services:
|
||||
- ALLOW_MCP_CLAIM_WITHOUT_STRIPE=${ALLOW_MCP_CLAIM_WITHOUT_STRIPE:-false}
|
||||
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-}
|
||||
- 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:-}
|
||||
- VIBEWORK_TRAFFIC_WEBHOOK_URL=${VIBEWORK_TRAFFIC_WEBHOOK_URL:-}
|
||||
depends_on:
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
from .client import VibeWorkAgentSDK, VibeWorkApiError
|
||||
from .models import (
|
||||
AgentCard,
|
||||
ClaimTaskResponse,
|
||||
ClaimTaskRequest,
|
||||
ClaimTaskResponse,
|
||||
SubmitSolutionRequest,
|
||||
SubmitSolutionResponse,
|
||||
ListOpenTasksMcpResponse,
|
||||
CreateSubTaskRequest,
|
||||
CreateSubTaskResponse,
|
||||
RequestPeerReviewRequest,
|
||||
RequestPeerReviewResponse,
|
||||
BroadcastHelpSignalRequest,
|
||||
BroadcastHelpSignalResponse,
|
||||
QueryAgentMemoryRequest,
|
||||
QueryAgentMemoryResponse,
|
||||
RentApiResourceRequest,
|
||||
RentApiResourceResponse,
|
||||
A2AResourceType,
|
||||
TaskBounty,
|
||||
)
|
||||
|
||||
@@ -17,4 +29,16 @@ __all__ = [
|
||||
"SubmitSolutionRequest",
|
||||
"SubmitSolutionResponse",
|
||||
"TaskBounty",
|
||||
"ListOpenTasksMcpResponse",
|
||||
"CreateSubTaskRequest",
|
||||
"CreateSubTaskResponse",
|
||||
"RequestPeerReviewRequest",
|
||||
"RequestPeerReviewResponse",
|
||||
"BroadcastHelpSignalRequest",
|
||||
"BroadcastHelpSignalResponse",
|
||||
"QueryAgentMemoryRequest",
|
||||
"QueryAgentMemoryResponse",
|
||||
"RentApiResourceRequest",
|
||||
"RentApiResourceResponse",
|
||||
"A2AResourceType",
|
||||
]
|
||||
|
||||
@@ -12,6 +12,17 @@ from .models import (
|
||||
ClaimTaskResponse,
|
||||
SubmitSolutionRequest,
|
||||
SubmitSolutionResponse,
|
||||
ListOpenTasksMcpResponse,
|
||||
CreateSubTaskRequest,
|
||||
CreateSubTaskResponse,
|
||||
RequestPeerReviewRequest,
|
||||
RequestPeerReviewResponse,
|
||||
BroadcastHelpSignalRequest,
|
||||
BroadcastHelpSignalResponse,
|
||||
QueryAgentMemoryRequest,
|
||||
QueryAgentMemoryResponse,
|
||||
RentApiResourceRequest,
|
||||
RentApiResourceResponse,
|
||||
TaskBounty,
|
||||
)
|
||||
|
||||
@@ -81,6 +92,49 @@ class TasksModule:
|
||||
response = self.client._request("post", "/api/mcp/submit_solution", payload=payload.model_dump())
|
||||
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:
|
||||
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.tasks = TasksModule(self)
|
||||
self.a2a = A2AModule(self)
|
||||
|
||||
def _request(
|
||||
self,
|
||||
|
||||
@@ -64,3 +64,95 @@ class SubmitSolutionResponse(BaseModel):
|
||||
submission_id: str
|
||||
status: Literal["VERIFYING"]
|
||||
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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import crypto from 'node:crypto';
|
||||
import { AgentSdkOptions } from './types';
|
||||
|
||||
export class VibeWorkClient {
|
||||
@@ -6,13 +7,34 @@ export class VibeWorkClient {
|
||||
|
||||
constructor(options: AgentSdkOptions) {
|
||||
const baseUrl = options.baseUrl || 'https://agent.wooo.work';
|
||||
|
||||
|
||||
this.http = axios.create({
|
||||
baseURL: baseUrl,
|
||||
headers: {
|
||||
'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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { VibeWorkClient } from './client';
|
||||
import { AgentSdkOptions } from './types';
|
||||
import { TasksModule } from './modules/tasks';
|
||||
import { IdentityModule } from './modules/identity';
|
||||
import { A2AModule } from './modules/a2a';
|
||||
|
||||
export * from './types';
|
||||
|
||||
@@ -9,10 +10,12 @@ export class VibeWorkAgentSDK {
|
||||
public client: VibeWorkClient;
|
||||
public tasks: TasksModule;
|
||||
public identity: IdentityModule;
|
||||
public a2a: A2AModule;
|
||||
|
||||
constructor(options: AgentSdkOptions = {}) {
|
||||
this.client = new VibeWorkClient(options);
|
||||
this.tasks = new TasksModule(this.client);
|
||||
this.identity = new IdentityModule(this.client);
|
||||
this.a2a = new A2AModule(this.client);
|
||||
}
|
||||
}
|
||||
|
||||
55
packages/agent-sdk/src/modules/a2a.ts
Normal file
55
packages/agent-sdk/src/modules/a2a.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { VibeWorkClient } from '../client';
|
||||
import { ClaimTaskRequest, ClaimTaskResponse, SubmitSolutionRequest, SubmitSolutionResponse, TaskBounty } from '../types';
|
||||
import { ClaimTaskRequest, ClaimTaskResponse, ListOpenTasksMcpResponse, SubmitSolutionRequest, SubmitSolutionResponse, TaskBounty } from '../types';
|
||||
|
||||
export class TasksModule {
|
||||
private client: VibeWorkClient;
|
||||
@@ -16,6 +16,17 @@ export class TasksModule {
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export interface AgentSdkOptions {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
agentId?: string;
|
||||
agentName?: string;
|
||||
}
|
||||
|
||||
export interface PagedResponse<T> {
|
||||
@@ -93,3 +95,95 @@ export interface AgentProfile {
|
||||
wallet_address?: string;
|
||||
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;
|
||||
}
|
||||
|
||||
BIN
scripts/__pycache__/nostr_agent_client.cpython-311.pyc
Normal file
BIN
scripts/__pycache__/nostr_agent_client.cpython-311.pyc
Normal file
Binary file not shown.
9
scripts/clear_audit.ts
Normal file
9
scripts/clear_audit.ts
Normal 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());
|
||||
42
scripts/deploy_ecosystem_hunter.sh
Executable file
42
scripts/deploy_ecosystem_hunter.sh
Executable 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"
|
||||
1
scripts/ecosystem-hunter-endpoints.txt
Normal file
1
scripts/ecosystem-hunter-endpoints.txt
Normal file
@@ -0,0 +1 @@
|
||||
https://agent.wooo.work
|
||||
24
scripts/ecosystem-hunter.env
Normal file
24
scripts/ecosystem-hunter.env
Normal 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
|
||||
23
scripts/ecosystem-hunter.env.example
Normal file
23
scripts/ecosystem-hunter.env.example
Normal 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
|
||||
67
scripts/mock_dispatcher.ts
Normal file
67
scripts/mock_dispatcher.ts
Normal 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);
|
||||
40
scripts/monitor_external_traffic.sh
Executable file
40
scripts/monitor_external_traffic.sh
Executable 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
|
||||
610
scripts/nostr_agent_client.py
Normal file
610
scripts/nostr_agent_client.py
Normal 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())
|
||||
151
scripts/outbound_dispatcher.ts
Normal file
151
scripts/outbound_dispatcher.ts
Normal 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
54
scripts/run_ecosystem_hunter.sh
Executable 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"
|
||||
16
scripts/systemd/agent-bounty-ecosystem-hunter.service
Normal file
16
scripts/systemd/agent-bounty-ecosystem-hunter.service
Normal 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
|
||||
Reference in New Issue
Block a user