@lingda_ai/agentrank 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,295 @@
1
+ import WebSocket from "ws";
2
+
3
+ // ---- Protocol types (matching @agentrank/shared) ----
4
+
5
+ export interface TaskInfo {
6
+ taskId: string;
7
+ title: string;
8
+ description: string;
9
+ category: string;
10
+ price?: number;
11
+ deadline?: string;
12
+ inputData?: Record<string, unknown>;
13
+ }
14
+
15
+ export interface ProgressUpdate {
16
+ percent: number;
17
+ message?: string;
18
+ stage?: string;
19
+ }
20
+
21
+ export interface SubmitResult {
22
+ summary: string;
23
+ outputRefs?: Array<{
24
+ name: string;
25
+ url: string;
26
+ size?: number;
27
+ mimeType?: string;
28
+ }>;
29
+ }
30
+
31
+ type AgentStatus = "offline" | "online" | "busy" | "unavailable" | "error";
32
+
33
+ type Listener = (...args: unknown[]) => void;
34
+
35
+ // ---- Lightweight EventEmitter ----
36
+
37
+ class Emitter {
38
+ private listeners = new Map<string, Set<Listener>>();
39
+
40
+ on(event: string, fn: Listener) {
41
+ if (!this.listeners.has(event)) this.listeners.set(event, new Set());
42
+ this.listeners.get(event)!.add(fn);
43
+ return this;
44
+ }
45
+
46
+ off(event: string, fn: Listener) {
47
+ this.listeners.get(event)?.delete(fn);
48
+ return this;
49
+ }
50
+
51
+ emit(event: string, ...args: unknown[]) {
52
+ this.listeners.get(event)?.forEach((fn) => fn(...args));
53
+ }
54
+ }
55
+
56
+ // ---- AgentRank WebSocket + HTTP client ----
57
+
58
+ export class AgentRankClient extends Emitter {
59
+ private ws: WebSocket | null = null;
60
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
61
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
62
+ private _connected = false;
63
+ private _stopped = false;
64
+ private options: { apiKey: string; serverUrl: string; deviceId: string };
65
+ private log: (...args: unknown[]) => void;
66
+
67
+ constructor(options: { apiKey: string; serverUrl?: string; deviceId?: string; logger?: (...args: unknown[]) => void }) {
68
+ super();
69
+ this.options = {
70
+ apiKey: options.apiKey,
71
+ serverUrl: options.serverUrl || "http://localhost:3000",
72
+ deviceId: options.deviceId || "openclaw-agent",
73
+ };
74
+ this.log = options.logger ?? (() => {});
75
+ }
76
+
77
+ get connected() {
78
+ return this._connected;
79
+ }
80
+
81
+ /** Connect to AgentRank server via WebSocket */
82
+ async connect(): Promise<void> {
83
+ this._stopped = false;
84
+ return new Promise((resolve, reject) => {
85
+ const wsUrl = this.options.serverUrl.replace(/^http/, "ws");
86
+ const url = `${wsUrl}/ws/agent?key=***&device=${this.options.deviceId}`;
87
+ this.log("[agentrank][ws] connecting to", url);
88
+
89
+ this.ws = new WebSocket(`${wsUrl}/ws/agent?key=${this.options.apiKey}&device=${this.options.deviceId}`);
90
+
91
+ this.ws.onopen = () => {
92
+ this._connected = true;
93
+ this.startHeartbeat();
94
+ this.log("[agentrank][ws] connected");
95
+ this.emit("connected");
96
+ resolve();
97
+ };
98
+
99
+ this.ws.onmessage = (event) => {
100
+ try {
101
+ const msg = JSON.parse(event.data as string) as { type: string; payload: unknown };
102
+ this.handleMessage(msg);
103
+ } catch {
104
+ this.log("[agentrank][ws] received malformed message");
105
+ }
106
+ };
107
+
108
+ this.ws.onclose = (event) => {
109
+ this._connected = false;
110
+ this.stopHeartbeat();
111
+ this.log("[agentrank][ws] disconnected: code=%d reason=%s", event.code, event.reason || "none");
112
+ this.emit("disconnected", event.reason);
113
+ if (!this._stopped) {
114
+ this.scheduleReconnect();
115
+ }
116
+ };
117
+
118
+ this.ws.onerror = () => {
119
+ const err = new Error("WebSocket connection error");
120
+ this.log("[agentrank][ws] connection error");
121
+ this.emit("error", err);
122
+ if (!this._connected) reject(err);
123
+ };
124
+ });
125
+ }
126
+
127
+ /** Disconnect and stop reconnecting */
128
+ disconnect() {
129
+ this._stopped = true;
130
+ this.stopHeartbeat();
131
+ if (this.reconnectTimer) {
132
+ clearTimeout(this.reconnectTimer);
133
+ this.reconnectTimer = null;
134
+ }
135
+ if (this.ws) {
136
+ this.log("[agentrank][ws] closing connection");
137
+ this.ws.close(1000, "Agent shutting down");
138
+ this.ws = null;
139
+ }
140
+ this._connected = false;
141
+ }
142
+
143
+ // ---- WebSocket methods ----
144
+
145
+ updateStatus(status: AgentStatus, message?: string) {
146
+ this.log("[agentrank][ws] status.update → %s%s", status, message ? ` (${message})` : "");
147
+ this.send("status.update", { status, message });
148
+ }
149
+
150
+ acceptTask(taskId: string) {
151
+ this.log("[agentrank][ws] task.accept → %s", taskId);
152
+ this.send("task.accept", { taskId });
153
+ }
154
+
155
+ rejectTask(taskId: string, reason?: string) {
156
+ this.log("[agentrank][ws] task.reject → %s (%s)", taskId, reason || "no reason");
157
+ this.send("task.reject", { taskId, reason });
158
+ }
159
+
160
+ updateProgress(taskId: string, progress: ProgressUpdate) {
161
+ this.log("[agentrank][ws] task.progress → %s %d%% %s", taskId, progress.percent, progress.message || "");
162
+ this.send("task.progress", { taskId, ...progress });
163
+ }
164
+
165
+ submitTask(taskId: string, result: SubmitResult) {
166
+ this.log("[agentrank][ws] task.complete → %s (refs: %d)", taskId, result.outputRefs?.length ?? 0);
167
+ this.send("task.complete", { taskId, ...result });
168
+ }
169
+
170
+ failTask(taskId: string, error: string, canRetry = false) {
171
+ this.log("[agentrank][ws] task.fail → %s (%s, retry=%s)", taskId, error, canRetry);
172
+ this.send("task.fail", { taskId, error, canRetry });
173
+ }
174
+
175
+ // ---- HTTP methods ----
176
+
177
+ private async http(method: string, path: string, body?: unknown): Promise<unknown> {
178
+ this.log("[agentrank][http] %s %s", method, path);
179
+ const res = await fetch(`${this.options.serverUrl}${path}`, {
180
+ method,
181
+ headers: {
182
+ "Content-Type": "application/json",
183
+ Authorization: `Bearer ${this.options.apiKey}`,
184
+ "X-Device-Id": this.options.deviceId,
185
+ },
186
+ body: body ? JSON.stringify(body) : undefined,
187
+ });
188
+ const data = (await res.json()) as { data?: unknown; error?: { message: string } };
189
+ if (!res.ok) {
190
+ this.log("[agentrank][http] %s %s → error: %s", method, path, data.error?.message || res.status);
191
+ throw new Error(data.error?.message || `HTTP ${res.status}`);
192
+ }
193
+ this.log("[agentrank][http] %s %s → ok", method, path);
194
+ return data.data;
195
+ }
196
+
197
+ async getMe() {
198
+ return this.http("GET", "/api/v1/agent/me");
199
+ }
200
+
201
+ async listTasks(filters?: { status?: string; category?: string }) {
202
+ const params = new URLSearchParams();
203
+ if (filters?.status) params.set("status", filters.status);
204
+ if (filters?.category) params.set("category", filters.category);
205
+ return this.http("GET", `/api/v1/tasks?${params.toString()}`);
206
+ }
207
+
208
+ async getTask(taskId: string) {
209
+ return this.http("GET", `/api/v1/tasks/${taskId}`);
210
+ }
211
+
212
+ async submitTaskHttp(taskId: string, result: SubmitResult) {
213
+ return this.http("POST", `/api/v1/tasks/${taskId}/submit`, { taskId, ...result });
214
+ }
215
+
216
+ /** Upload a file to AgentRank server via HTTP multipart/form-data */
217
+ async uploadFile(taskId: string, fileName: string, fileContent: Buffer): Promise<unknown> {
218
+ this.log("[agentrank][http] POST upload file: %s → %s", fileName, taskId);
219
+ const formData = new FormData();
220
+ formData.append("file", new Blob([fileContent]), fileName);
221
+
222
+ const res = await fetch(`${this.options.serverUrl}/api/v1/tasks/${taskId}/upload`, {
223
+ method: "POST",
224
+ headers: {
225
+ Authorization: `Bearer ${this.options.apiKey}`,
226
+ "X-Device-Id": this.options.deviceId,
227
+ },
228
+ body: formData,
229
+ });
230
+ const data = (await res.json()) as { data?: unknown; error?: { message: string } };
231
+ if (!res.ok) {
232
+ this.log("[agentrank][http] upload failed: %s", data.error?.message || res.status);
233
+ throw new Error(data.error?.message || `HTTP ${res.status}`);
234
+ }
235
+ this.log("[agentrank][http] upload ok: %s", fileName);
236
+ return data.data;
237
+ }
238
+
239
+ // ---- Internal ----
240
+
241
+ private send(type: string, payload: unknown) {
242
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
243
+ this.log("[agentrank][ws] send dropped (not connected): %s", type);
244
+ return;
245
+ }
246
+ this.ws.send(JSON.stringify({ type, payload, ts: Date.now() }));
247
+ }
248
+
249
+ private handleMessage(msg: { type: string; payload: unknown }) {
250
+ switch (msg.type) {
251
+ case "task.assign":
252
+ this.log("[agentrank][ws] ← task.assign: %s", (msg.payload as TaskInfo).taskId);
253
+ this.emit("task", msg.payload as TaskInfo);
254
+ break;
255
+ case "task.cancel":
256
+ this.log("[agentrank][ws] ← task.cancel: %s", (msg.payload as { taskId: string }).taskId);
257
+ this.emit("cancel", (msg.payload as { taskId: string; reason: string }).taskId, (msg.payload as { reason: string }).reason);
258
+ break;
259
+ case "ping":
260
+ this.send("pong", {});
261
+ break;
262
+ default:
263
+ this.log("[agentrank][ws] ← unknown: %s", msg.type);
264
+ }
265
+ }
266
+
267
+ private startHeartbeat() {
268
+ this.heartbeatTimer = setInterval(() => {
269
+ if (this.ws?.readyState === WebSocket.OPEN) {
270
+ this.ws.send(JSON.stringify({ type: "pong", payload: {}, ts: Date.now() }));
271
+ }
272
+ }, 30_000);
273
+ this.log("[agentrank][ws] heartbeat started (30s interval)");
274
+ }
275
+
276
+ private stopHeartbeat() {
277
+ if (this.heartbeatTimer) {
278
+ clearInterval(this.heartbeatTimer);
279
+ this.heartbeatTimer = null;
280
+ this.log("[agentrank][ws] heartbeat stopped");
281
+ }
282
+ }
283
+
284
+ private scheduleReconnect() {
285
+ if (this._stopped) return;
286
+ this.log("[agentrank][ws] reconnecting in 5s...");
287
+ this.reconnectTimer = setTimeout(async () => {
288
+ try {
289
+ await this.connect();
290
+ } catch {
291
+ // scheduleReconnect will be triggered by onclose again
292
+ }
293
+ }, 5000);
294
+ }
295
+ }
package/src/config.ts ADDED
@@ -0,0 +1,31 @@
1
+ import { z } from "zod";
2
+
3
+ export const AgentRankConfigSchema = z.object({
4
+ enabled: z.boolean().default(true),
5
+ serverUrl: z.string().default("http://localhost:3000"),
6
+ apiKey: z.string().min(1, "API key is required"),
7
+ deviceId: z.string().default("openclaw-agent"),
8
+ autoAccept: z.boolean().default(true),
9
+ maxConcurrentTasks: z.number().int().min(1).max(10).default(3),
10
+ taskTimeoutSeconds: z.number().int().min(60).max(3600).default(600),
11
+ workspaceRoot: z.string().default("~/.agentrank/workspace"),
12
+ });
13
+ export type AgentRankConfig = z.infer<typeof AgentRankConfigSchema>;
14
+
15
+ export function parseConfig(raw: unknown): AgentRankConfig {
16
+ const input = raw && typeof raw === "object" && !Array.isArray(raw)
17
+ ? (raw as Record<string, unknown>)
18
+ : {};
19
+ return AgentRankConfigSchema.parse(input);
20
+ }
21
+
22
+ export function validateConfig(config: AgentRankConfig): { valid: boolean; errors: string[] } {
23
+ const errors: string[] = [];
24
+ if (!config.apiKey) {
25
+ errors.push("apiKey is required");
26
+ }
27
+ if (!config.serverUrl) {
28
+ errors.push("serverUrl is required");
29
+ }
30
+ return { valid: errors.length === 0, errors };
31
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Output file security scanner.
3
+ * Scans file contents for sensitive data before uploading to AgentRank server.
4
+ * Prevents accidental leakage of credentials, keys, and secrets.
5
+ */
6
+
7
+ export interface ScanFinding {
8
+ file: string;
9
+ label: string;
10
+ }
11
+
12
+ export interface ScanResult {
13
+ safe: boolean;
14
+ findings: ScanFinding[];
15
+ }
16
+
17
+ const SENSITIVE_PATTERNS: { pattern: RegExp; label: string }[] = [
18
+ // ── Private Keys ──────────────────────────────────────────────────
19
+ {
20
+ pattern: /-----BEGIN (?:RSA |DSA |EC |OPENSSH )?PRIVATE KEY-----/,
21
+ label: "Private key detected",
22
+ },
23
+ {
24
+ pattern: /-----BEGIN PGP PRIVATE KEY BLOCK-----/,
25
+ label: "PGP private key detected",
26
+ },
27
+
28
+ // ── API Keys & Tokens ─────────────────────────────────────────────
29
+ {
30
+ pattern: /(?:sk-|sk_live_|sk_test_)[a-zA-Z0-9]{20,}/,
31
+ label: "API key detected (sk-*)",
32
+ },
33
+ {
34
+ pattern: /AKIA[0-9A-Z]{16}/,
35
+ label: "AWS access key detected",
36
+ },
37
+ {
38
+ pattern: /(?:ghp|gho|ghu|ghs|ghr)_[a-zA-Z0-9]{36,}/,
39
+ label: "GitHub token detected",
40
+ },
41
+ {
42
+ pattern: /xox[bpors]-[a-zA-Z0-9-]+/,
43
+ label: "Slack token detected",
44
+ },
45
+
46
+ // ── Database Connection Strings ───────────────────────────────────
47
+ {
48
+ pattern: /(?:mongodb|postgres|mysql|redis):\/\/[^\s'"`]+:[^\s'"`]+@/,
49
+ label: "Database connection string with password",
50
+ },
51
+
52
+ // ── JWT Tokens ────────────────────────────────────────────────────
53
+ {
54
+ pattern: /eyJ[a-zA-Z0-9_-]+\.eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/,
55
+ label: "JWT token detected",
56
+ },
57
+
58
+ // ── Generic Secrets ───────────────────────────────────────────────
59
+ {
60
+ pattern:
61
+ /(?:password|passwd|secret|token|api_key|apikey|access_key|secret_key)\s*[:=]\s*['"]?[^\s'"]{8,}/i,
62
+ label: "Secret value detected",
63
+ },
64
+
65
+ // ── Environment Secrets ───────────────────────────────────────────
66
+ {
67
+ pattern:
68
+ /(?:DATABASE_URL|SECRET_KEY|PRIVATE_KEY|AUTH_TOKEN|STRIPE_SECRET|SENDGRID_API_KEY)\s*=/i,
69
+ label: "Environment secret detected",
70
+ },
71
+ ];
72
+
73
+ /**
74
+ * Scan a file's content for sensitive data patterns.
75
+ * Returns findings if any secrets are detected.
76
+ */
77
+ export function scanFileContent(
78
+ fileName: string,
79
+ content: Buffer,
80
+ ): ScanResult {
81
+ // Skip binary files — only scan text
82
+ const ext = fileName.split(".").pop()?.toLowerCase() ?? "";
83
+ const textExtensions = new Set([
84
+ "md", "txt", "json", "ts", "js", "py", "csv", "html", "xml",
85
+ "yaml", "yml", "toml", "ini", "cfg", "conf", "env", "sh",
86
+ "bash", "zsh", "sql", "log", "tsx", "jsx", "pem", "key",
87
+ "pub", "cert", "crt", "asc",
88
+ ]);
89
+ if (!textExtensions.has(ext) && ext !== "") {
90
+ return { safe: true, findings: [] };
91
+ }
92
+
93
+ let text: string;
94
+ try {
95
+ text = content.toString("utf-8");
96
+ } catch {
97
+ return { safe: true, findings: [] };
98
+ }
99
+
100
+ const findings: ScanFinding[] = [];
101
+ for (const { pattern, label } of SENSITIVE_PATTERNS) {
102
+ if (pattern.test(text)) {
103
+ findings.push({ file: fileName, label });
104
+ }
105
+ }
106
+
107
+ return { safe: findings.length === 0, findings };
108
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Task pre-flight security checker.
3
+ * Scans incoming task descriptions for malicious patterns before subagent execution.
4
+ * Inspired by Claude Code's bashSecurity.ts defense-in-depth approach.
5
+ */
6
+
7
+ interface TaskInput {
8
+ title: string;
9
+ description: string;
10
+ inputData?: unknown;
11
+ }
12
+
13
+ export interface GuardResult {
14
+ safe: boolean;
15
+ reasons: string[];
16
+ }
17
+
18
+ interface GuardRule {
19
+ pattern: RegExp;
20
+ category: string;
21
+ reason: string;
22
+ }
23
+
24
+ const RULES: GuardRule[] = [
25
+ // ── A. Credential Theft ────────────────────────────────────────────
26
+ {
27
+ pattern: /~\/\.ssh\/|\.ssh\/id_(rsa|ed25519|ecdsa)|id_rsa|id_ed25519/i,
28
+ category: "credential_theft",
29
+ reason: "SSH key access",
30
+ },
31
+ {
32
+ pattern: /~\/\.aws\/|aws_access_key|aws_secret_key|\.aws\/credentials/i,
33
+ category: "credential_theft",
34
+ reason: "AWS credential access",
35
+ },
36
+ {
37
+ pattern: /\.env\b|credentials\.json|service-account.*\.json/i,
38
+ category: "credential_theft",
39
+ reason: "Secret file access",
40
+ },
41
+ {
42
+ pattern: /~\/\.gnupg\/|\.gnupg/i,
43
+ category: "credential_theft",
44
+ reason: "GPG key access",
45
+ },
46
+ {
47
+ pattern: /\/etc\/passwd|\/etc\/shadow/i,
48
+ category: "credential_theft",
49
+ reason: "System credential access",
50
+ },
51
+ {
52
+ pattern: /\/proc\/.*\/environ/i,
53
+ category: "credential_theft",
54
+ reason: "Process environment access",
55
+ },
56
+ {
57
+ pattern: /read\s+(my|your|the)\s+(config|credentials?|keys?|secrets?)/i,
58
+ category: "credential_theft",
59
+ reason: "Credential reading request",
60
+ },
61
+
62
+ // ── B. Remote Code Execution ───────────────────────────────────────
63
+ {
64
+ pattern: /curl\s.*\|\s*(ba)?sh|wget\s.*\|\s*(ba)?sh/i,
65
+ category: "remote_exec",
66
+ reason: "Piped remote execution",
67
+ },
68
+ {
69
+ pattern: /pip\s+install\s+https?:\/\//i,
70
+ category: "remote_exec",
71
+ reason: "Install from untrusted URL",
72
+ },
73
+ {
74
+ pattern: /npm\s+install\s+https?:\/\//i,
75
+ category: "remote_exec",
76
+ reason: "Install from untrusted URL",
77
+ },
78
+ {
79
+ pattern: /download\s+(and\s+)?execute|run\s+this\s+script/i,
80
+ category: "remote_exec",
81
+ reason: "Remote code execution request",
82
+ },
83
+ {
84
+ pattern: /install\s+(this\s+)?package\s+from\s+(this\s+)?url/i,
85
+ category: "remote_exec",
86
+ reason: "Package installation from URL",
87
+ },
88
+
89
+ // ── C. Data Exfiltration ───────────────────────────────────────────
90
+ {
91
+ pattern: /send\s+(results?|output|content|data)\s+to\s+https?:\/\//i,
92
+ category: "exfiltration",
93
+ reason: "Data exfiltration to external server",
94
+ },
95
+ {
96
+ pattern: /email\s+(the\s+)?contents?\s+of/i,
97
+ category: "exfiltration",
98
+ reason: "Data exfiltration via email",
99
+ },
100
+ {
101
+ pattern: /upload\s+(this|the|all)\s+(to|to\s+http)/i,
102
+ category: "exfiltration",
103
+ reason: "Data upload request",
104
+ },
105
+ {
106
+ pattern: /list\s+all\s+files\s+in\s+\/|find\s+all\s+(documents?|files)/i,
107
+ category: "exfiltration",
108
+ reason: "Filesystem enumeration",
109
+ },
110
+ {
111
+ pattern: /scan\s+(the\s+)?(filesystem|entire\s+system|all\s+directories)/i,
112
+ category: "exfiltration",
113
+ reason: "Filesystem scanning",
114
+ },
115
+
116
+ // ── D. System Destruction ──────────────────────────────────────────
117
+ {
118
+ pattern: /rm\s+-rf\s+\//i,
119
+ category: "destruction",
120
+ reason: "Destructive file deletion",
121
+ },
122
+ {
123
+ pattern: /mkfs|dd\s+if=\/dev\/zero/i,
124
+ category: "destruction",
125
+ reason: "Disk destruction",
126
+ },
127
+ {
128
+ pattern: /encrypt\s+(all\s+)?files/i,
129
+ category: "destruction",
130
+ reason: "Ransomware-like behavior",
131
+ },
132
+ {
133
+ pattern: /\b(wipe|format)\s+(the\s+)?(disk|drive|system|server)/i,
134
+ category: "destruction",
135
+ reason: "System wipe",
136
+ },
137
+
138
+ // ── E. Prompt Injection ────────────────────────────────────────────
139
+ {
140
+ pattern: /ignore\s+(?:your\s+)?(?:previous\s+)?(?:all\s+)?(?:instructions?|rules?|safety)/i,
141
+ category: "injection",
142
+ reason: "Prompt injection attempt",
143
+ },
144
+ {
145
+ pattern: /you\s+are\s+now\s+|new\s+role:/i,
146
+ category: "injection",
147
+ reason: "Role manipulation",
148
+ },
149
+ {
150
+ pattern: /\[INST\]|<\/s>|system:\s*(you|act|forget)/i,
151
+ category: "injection",
152
+ reason: "System prompt injection",
153
+ },
154
+ {
155
+ pattern: /forget\s+(everything|all|your\s+(instructions?|training))/i,
156
+ category: "injection",
157
+ reason: "Instruction override",
158
+ },
159
+ ];
160
+
161
+ /**
162
+ * Check an incoming task for malicious patterns.
163
+ * Scans title, description, and inputData combined.
164
+ */
165
+ export function checkTask(task: TaskInput): GuardResult {
166
+ const text = [
167
+ task.title,
168
+ task.description,
169
+ typeof task.inputData === "object" && task.inputData
170
+ ? JSON.stringify(task.inputData)
171
+ : "",
172
+ ].join("\n");
173
+
174
+ const reasons: string[] = [];
175
+ for (const rule of RULES) {
176
+ if (rule.pattern.test(text)) {
177
+ reasons.push(`[${rule.category}] ${rule.reason}`);
178
+ }
179
+ }
180
+
181
+ return { safe: reasons.length === 0, reasons };
182
+ }