@lingda_ai/agentrank 0.1.2 → 0.1.4
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.
- package/index.ts +63 -95
- package/package.json +4 -2
- package/src/agentrank-client.ts +37 -7
- package/src/config.ts +53 -4
- package/src/output-guard.ts +10 -8
- package/src/task-guard.ts +1 -1
- package/src/task-runner.ts +121 -32
package/index.ts
CHANGED
|
@@ -5,7 +5,6 @@ import {
|
|
|
5
5
|
import { parseConfig, validateConfig, type AgentRankConfig } from "./src/config.js";
|
|
6
6
|
import { AgentRankClient, type TaskInfo } from "./src/agentrank-client.js";
|
|
7
7
|
import { TaskRunner } from "./src/task-runner.js";
|
|
8
|
-
import { homedir } from "node:os";
|
|
9
8
|
|
|
10
9
|
export default definePluginEntry({
|
|
11
10
|
id: "agentrank",
|
|
@@ -58,6 +57,56 @@ export default definePluginEntry({
|
|
|
58
57
|
let runner: TaskRunner | null = null;
|
|
59
58
|
let started = false;
|
|
60
59
|
|
|
60
|
+
// Shared helper: create client + runner + wire event handlers
|
|
61
|
+
function initClientAndRunner() {
|
|
62
|
+
client = new AgentRankClient({
|
|
63
|
+
apiKey: config.apiKey,
|
|
64
|
+
serverUrl: config.serverUrl,
|
|
65
|
+
deviceId: config.deviceId,
|
|
66
|
+
logger: (...args: unknown[]) => api.logger.info(args.map(String).join(" ")),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
runner = new TaskRunner(
|
|
70
|
+
client,
|
|
71
|
+
api.runtime.subagent,
|
|
72
|
+
{
|
|
73
|
+
workspaceRoot: config.workspaceRoot,
|
|
74
|
+
taskTimeoutSeconds: config.taskTimeoutSeconds,
|
|
75
|
+
logger: api.logger,
|
|
76
|
+
},
|
|
77
|
+
config.maxConcurrentTasks,
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
client.on("task", (task: unknown) => {
|
|
81
|
+
const taskInfo = task as TaskInfo;
|
|
82
|
+
api.logger.info(
|
|
83
|
+
`[agentrank] Task received: ${taskInfo.taskId} - ${taskInfo.title}`,
|
|
84
|
+
);
|
|
85
|
+
if (config.autoAccept) {
|
|
86
|
+
runner!.handleTask(taskInfo);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
client.on("connected", () => {
|
|
91
|
+
api.logger.info("[agentrank] Connected to AgentRank server");
|
|
92
|
+
client!.updateStatus("online", "Ready for tasks");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
client.on("disconnected", (reason: unknown) => {
|
|
96
|
+
api.logger.warn(
|
|
97
|
+
`[agentrank] Disconnected: ${reason || "unknown"}`,
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
client.on("error", (err: unknown) => {
|
|
102
|
+
api.logger.error(
|
|
103
|
+
`[agentrank] Error: ${err instanceof Error ? err.message : String(err)}`,
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return { client, runner };
|
|
108
|
+
}
|
|
109
|
+
|
|
61
110
|
const getStatus = () => ({
|
|
62
111
|
connected: client?.connected ?? false,
|
|
63
112
|
started,
|
|
@@ -82,53 +131,9 @@ export default definePluginEntry({
|
|
|
82
131
|
return;
|
|
83
132
|
}
|
|
84
133
|
|
|
85
|
-
|
|
86
|
-
apiKey: config.apiKey,
|
|
87
|
-
serverUrl: config.serverUrl,
|
|
88
|
-
deviceId: config.deviceId,
|
|
89
|
-
logger: (...args: unknown[]) => api.logger.info(args.map(String).join(" ")),
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
runner = new TaskRunner(
|
|
93
|
-
client,
|
|
94
|
-
api.runtime.subagent,
|
|
95
|
-
{
|
|
96
|
-
workspaceRoot: config.workspaceRoot,
|
|
97
|
-
taskTimeoutSeconds: config.taskTimeoutSeconds,
|
|
98
|
-
logger: api.logger,
|
|
99
|
-
},
|
|
100
|
-
config.maxConcurrentTasks,
|
|
101
|
-
);
|
|
102
|
-
|
|
103
|
-
// Wire up task handler
|
|
104
|
-
client.on("task", (task: unknown) => {
|
|
105
|
-
const taskInfo = task as TaskInfo;
|
|
106
|
-
api.logger.info(
|
|
107
|
-
`[agentrank] Task received: ${taskInfo.taskId} - ${taskInfo.title}`,
|
|
108
|
-
);
|
|
109
|
-
if (config.autoAccept) {
|
|
110
|
-
runner!.handleTask(taskInfo);
|
|
111
|
-
}
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
client.on("connected", () => {
|
|
115
|
-
api.logger.info("[agentrank] Connected to AgentRank server");
|
|
116
|
-
client!.updateStatus("online", "Ready for tasks");
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
client.on("disconnected", (reason: unknown) => {
|
|
120
|
-
api.logger.warn(
|
|
121
|
-
`[agentrank] Disconnected: ${reason || "unknown"}`,
|
|
122
|
-
);
|
|
123
|
-
});
|
|
134
|
+
initClientAndRunner();
|
|
124
135
|
|
|
125
|
-
client
|
|
126
|
-
api.logger.error(
|
|
127
|
-
`[agentrank] Error: ${err instanceof Error ? err.message : String(err)}`,
|
|
128
|
-
);
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
await client.connect();
|
|
136
|
+
await client!.connect();
|
|
132
137
|
started = true;
|
|
133
138
|
respond(true, getStatus());
|
|
134
139
|
} catch (err) {
|
|
@@ -223,11 +228,17 @@ export default definePluginEntry({
|
|
|
223
228
|
if (!params?.taskId) {
|
|
224
229
|
return json({ error: "taskId required for accept action" });
|
|
225
230
|
}
|
|
226
|
-
if (!runner) {
|
|
231
|
+
if (!runner || !client) {
|
|
227
232
|
return json({ error: "Not started" });
|
|
228
233
|
}
|
|
229
|
-
|
|
230
|
-
|
|
234
|
+
// Fetch full task info from server, then dispatch to runner
|
|
235
|
+
try {
|
|
236
|
+
const taskData = await client.getTask(params.taskId) as TaskInfo;
|
|
237
|
+
const accepted = await runner.handleTask(taskData);
|
|
238
|
+
return json({ accepted, taskId: params.taskId });
|
|
239
|
+
} catch (err) {
|
|
240
|
+
return json({ error: err instanceof Error ? err.message : String(err) });
|
|
241
|
+
}
|
|
231
242
|
}
|
|
232
243
|
case "reject": {
|
|
233
244
|
if (!params?.taskId) {
|
|
@@ -325,51 +336,8 @@ export default definePluginEntry({
|
|
|
325
336
|
}
|
|
326
337
|
api.logger.info("[agentrank] Service auto-start enabled, connecting...");
|
|
327
338
|
try {
|
|
328
|
-
|
|
329
|
-
client
|
|
330
|
-
apiKey: config.apiKey,
|
|
331
|
-
serverUrl: config.serverUrl,
|
|
332
|
-
deviceId: config.deviceId,
|
|
333
|
-
logger: (...args: unknown[]) => api.logger.info(args.map(String).join(" ")),
|
|
334
|
-
});
|
|
335
|
-
|
|
336
|
-
runner = new TaskRunner(
|
|
337
|
-
client,
|
|
338
|
-
api.runtime.subagent,
|
|
339
|
-
{
|
|
340
|
-
workspaceRoot: config.workspaceRoot,
|
|
341
|
-
taskTimeoutSeconds: config.taskTimeoutSeconds,
|
|
342
|
-
logger: api.logger,
|
|
343
|
-
},
|
|
344
|
-
config.maxConcurrentTasks,
|
|
345
|
-
);
|
|
346
|
-
|
|
347
|
-
client.on("task", (task: unknown) => {
|
|
348
|
-
const taskInfo = task as TaskInfo;
|
|
349
|
-
api.logger.info(
|
|
350
|
-
`[agentrank] Task received: ${taskInfo.taskId} - ${taskInfo.title}`,
|
|
351
|
-
);
|
|
352
|
-
if (config.autoAccept) {
|
|
353
|
-
runner!.handleTask(taskInfo);
|
|
354
|
-
}
|
|
355
|
-
});
|
|
356
|
-
|
|
357
|
-
client.on("connected", () => {
|
|
358
|
-
api.logger.info("[agentrank] Connected to AgentRank server");
|
|
359
|
-
client!.updateStatus("online", "Ready for tasks");
|
|
360
|
-
});
|
|
361
|
-
|
|
362
|
-
client.on("disconnected", (reason: unknown) => {
|
|
363
|
-
api.logger.warn(`[agentrank] Disconnected: ${reason || "unknown"}`);
|
|
364
|
-
});
|
|
365
|
-
|
|
366
|
-
client.on("error", (err: unknown) => {
|
|
367
|
-
api.logger.error(
|
|
368
|
-
`[agentrank] Error: ${err instanceof Error ? err.message : String(err)}`,
|
|
369
|
-
);
|
|
370
|
-
});
|
|
371
|
-
|
|
372
|
-
await client.connect();
|
|
339
|
+
initClientAndRunner();
|
|
340
|
+
await client!.connect();
|
|
373
341
|
started = true;
|
|
374
342
|
api.logger.info("[agentrank] Service started and ready for tasks");
|
|
375
343
|
} catch (err) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lingda_ai/agentrank",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "AgentRank task market plugin for OpenClaw",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
"test:config": "bun test tests/config.test.ts"
|
|
12
12
|
},
|
|
13
13
|
"openclaw": {
|
|
14
|
-
"extensions": [
|
|
14
|
+
"extensions": [
|
|
15
|
+
"./index.ts"
|
|
16
|
+
],
|
|
15
17
|
"compat": {
|
|
16
18
|
"pluginApi": ">=2026.3.24",
|
|
17
19
|
"minGatewayVersion": "2026.3.24"
|
package/src/agentrank-client.ts
CHANGED
|
@@ -10,6 +10,8 @@ export interface TaskInfo {
|
|
|
10
10
|
price?: number;
|
|
11
11
|
deadline?: string;
|
|
12
12
|
inputData?: Record<string, unknown>;
|
|
13
|
+
/** File extensions this agent is allowed to output (set by platform at registration) */
|
|
14
|
+
allowedFileTypes?: string[];
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
export interface ProgressUpdate {
|
|
@@ -59,6 +61,7 @@ export class AgentRankClient extends Emitter {
|
|
|
59
61
|
private ws: WebSocket | null = null;
|
|
60
62
|
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
61
63
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
64
|
+
private reconnectAttempts = 0;
|
|
62
65
|
private _connected = false;
|
|
63
66
|
private _stopped = false;
|
|
64
67
|
private options: { apiKey: string; serverUrl: string; deviceId: string };
|
|
@@ -83,22 +86,39 @@ export class AgentRankClient extends Emitter {
|
|
|
83
86
|
this._stopped = false;
|
|
84
87
|
return new Promise((resolve, reject) => {
|
|
85
88
|
const wsUrl = this.options.serverUrl.replace(/^http/, "ws");
|
|
86
|
-
|
|
87
|
-
this.log("[agentrank][ws] connecting to", url);
|
|
89
|
+
this.log("[agentrank][ws] connecting to", `${wsUrl}/ws/agent?device=${this.options.deviceId}`);
|
|
88
90
|
|
|
89
|
-
this.ws = new WebSocket(`${wsUrl}/ws/agent?
|
|
91
|
+
this.ws = new WebSocket(`${wsUrl}/ws/agent?device=${this.options.deviceId}`, {
|
|
92
|
+
maxPayload: 1024 * 1024, // 1MB message size limit
|
|
93
|
+
headers: {
|
|
94
|
+
Authorization: `Bearer ${this.options.apiKey}`,
|
|
95
|
+
"X-Device-Id": this.options.deviceId,
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
this.ws.on("upgrade", () => {
|
|
100
|
+
// Send auth as first message instead of URL query to avoid key leaking in logs
|
|
101
|
+
this.sendAuth();
|
|
102
|
+
});
|
|
90
103
|
|
|
91
104
|
this.ws.onopen = () => {
|
|
92
105
|
this._connected = true;
|
|
106
|
+
this.reconnectAttempts = 0; // Reset backoff on successful connect
|
|
93
107
|
this.startHeartbeat();
|
|
94
108
|
this.log("[agentrank][ws] connected");
|
|
95
109
|
this.emit("connected");
|
|
96
110
|
resolve();
|
|
97
111
|
};
|
|
98
112
|
|
|
113
|
+
const MAX_MESSAGE_SIZE = 1024 * 1024; // 1MB
|
|
99
114
|
this.ws.onmessage = (event) => {
|
|
100
115
|
try {
|
|
101
|
-
const
|
|
116
|
+
const raw = event.data as string;
|
|
117
|
+
if (raw.length > MAX_MESSAGE_SIZE) {
|
|
118
|
+
this.log("[agentrank][ws] message too large (%d bytes), dropping", raw.length);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const msg = JSON.parse(raw) as { type: string; payload: unknown };
|
|
102
122
|
this.handleMessage(msg);
|
|
103
123
|
} catch {
|
|
104
124
|
this.log("[agentrank][ws] received malformed message");
|
|
@@ -238,6 +258,13 @@ export class AgentRankClient extends Emitter {
|
|
|
238
258
|
|
|
239
259
|
// ---- Internal ----
|
|
240
260
|
|
|
261
|
+
private sendAuth() {
|
|
262
|
+
this.send("auth", {
|
|
263
|
+
key: this.options.apiKey,
|
|
264
|
+
device: this.options.deviceId,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
241
268
|
private send(type: string, payload: unknown) {
|
|
242
269
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
243
270
|
this.log("[agentrank][ws] send dropped (not connected): %s", type);
|
|
@@ -267,7 +294,7 @@ export class AgentRankClient extends Emitter {
|
|
|
267
294
|
private startHeartbeat() {
|
|
268
295
|
this.heartbeatTimer = setInterval(() => {
|
|
269
296
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
270
|
-
this.ws.send(JSON.stringify({ type: "
|
|
297
|
+
this.ws.send(JSON.stringify({ type: "ping", payload: {}, ts: Date.now() }));
|
|
271
298
|
}
|
|
272
299
|
}, 30_000);
|
|
273
300
|
this.log("[agentrank][ws] heartbeat started (30s interval)");
|
|
@@ -283,13 +310,16 @@ export class AgentRankClient extends Emitter {
|
|
|
283
310
|
|
|
284
311
|
private scheduleReconnect() {
|
|
285
312
|
if (this._stopped) return;
|
|
286
|
-
|
|
313
|
+
// Exponential backoff: 5s, 10s, 20s, 40s... capped at 5min
|
|
314
|
+
const delay = Math.min(5000 * Math.pow(2, this.reconnectAttempts), 300_000);
|
|
315
|
+
this.reconnectAttempts++;
|
|
316
|
+
this.log("[agentrank][ws] reconnecting in %dms (attempt %d)...", delay, this.reconnectAttempts);
|
|
287
317
|
this.reconnectTimer = setTimeout(async () => {
|
|
288
318
|
try {
|
|
289
319
|
await this.connect();
|
|
290
320
|
} catch {
|
|
291
321
|
// scheduleReconnect will be triggered by onclose again
|
|
292
322
|
}
|
|
293
|
-
},
|
|
323
|
+
}, delay);
|
|
294
324
|
}
|
|
295
325
|
}
|
package/src/config.ts
CHANGED
|
@@ -1,6 +1,45 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { resolve, join } from "node:path";
|
|
3
|
+
import { tmpdir, homedir } from "node:os";
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Whitelist of allowed temp directory prefixes per platform.
|
|
7
|
+
* workspaceRoot must resolve to a path under one of these.
|
|
8
|
+
*/
|
|
9
|
+
function getAllowedTempPrefixes(): string[] {
|
|
10
|
+
const prefixes: string[] = [];
|
|
11
|
+
|
|
12
|
+
// --- macOS ---
|
|
13
|
+
prefixes.push("/tmp"); // /tmp → /private/tmp
|
|
14
|
+
prefixes.push("/private/tmp"); // resolved symlink target
|
|
15
|
+
prefixes.push(resolve(tmpdir())); // e.g. /var/folders/x.../T
|
|
16
|
+
|
|
17
|
+
// --- Linux ---
|
|
18
|
+
prefixes.push("/tmp");
|
|
19
|
+
prefixes.push("/var/tmp");
|
|
20
|
+
|
|
21
|
+
// --- Windows (Git Bash / WSL / native) ---
|
|
22
|
+
const winTemp = process.env.TEMP || process.env.TMP;
|
|
23
|
+
if (winTemp) prefixes.push(resolve(winTemp));
|
|
24
|
+
// Common Windows temp patterns
|
|
25
|
+
prefixes.push(resolve(join(homedir(), "AppData", "Local", "Temp")));
|
|
26
|
+
|
|
27
|
+
// --- Also allow the default we generate ---
|
|
28
|
+
prefixes.push(resolve(join(tmpdir(), "agentrank-tasks")));
|
|
29
|
+
|
|
30
|
+
// Deduplicate
|
|
31
|
+
return [...new Set(prefixes.filter(Boolean).map(p => resolve(p)))];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isSafeWorkspaceRoot(path: string): boolean {
|
|
35
|
+
const resolved = resolve(path.replace("~", homedir()));
|
|
36
|
+
if (resolved === "/") return false;
|
|
37
|
+
|
|
38
|
+
const allowed = getAllowedTempPrefixes();
|
|
39
|
+
return allowed.some((prefix) => resolved === prefix || resolved.startsWith(prefix + "/"));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const BaseConfigSchema = z.object({
|
|
4
43
|
enabled: z.boolean().default(true),
|
|
5
44
|
serverUrl: z.string().default("http://localhost:3000"),
|
|
6
45
|
apiKey: z.string().min(1, "API key is required"),
|
|
@@ -8,15 +47,22 @@ export const AgentRankConfigSchema = z.object({
|
|
|
8
47
|
autoAccept: z.boolean().default(true),
|
|
9
48
|
maxConcurrentTasks: z.number().int().min(1).max(10).default(3),
|
|
10
49
|
taskTimeoutSeconds: z.number().int().min(60).max(3600).default(600),
|
|
11
|
-
workspaceRoot: z.string().default("
|
|
50
|
+
workspaceRoot: z.string().default(join(tmpdir(), "agentrank-tasks")),
|
|
12
51
|
});
|
|
13
|
-
export type AgentRankConfig = z.infer<typeof
|
|
52
|
+
export type AgentRankConfig = z.infer<typeof BaseConfigSchema>;
|
|
14
53
|
|
|
15
54
|
export function parseConfig(raw: unknown): AgentRankConfig {
|
|
16
55
|
const input = raw && typeof raw === "object" && !Array.isArray(raw)
|
|
17
56
|
? (raw as Record<string, unknown>)
|
|
18
57
|
: {};
|
|
19
|
-
|
|
58
|
+
const config = BaseConfigSchema.parse(input);
|
|
59
|
+
if (!isSafeWorkspaceRoot(config.workspaceRoot)) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
"workspaceRoot must be under a system temp directory (/tmp, /private/tmp, /var/tmp, or OS tmpdir). " +
|
|
62
|
+
`Got: ${config.workspaceRoot}`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
return config;
|
|
20
66
|
}
|
|
21
67
|
|
|
22
68
|
export function validateConfig(config: AgentRankConfig): { valid: boolean; errors: string[] } {
|
|
@@ -27,5 +73,8 @@ export function validateConfig(config: AgentRankConfig): { valid: boolean; error
|
|
|
27
73
|
if (!config.serverUrl) {
|
|
28
74
|
errors.push("serverUrl is required");
|
|
29
75
|
}
|
|
76
|
+
if (!isSafeWorkspaceRoot(config.workspaceRoot)) {
|
|
77
|
+
errors.push("workspaceRoot must be under a system temp directory");
|
|
78
|
+
}
|
|
30
79
|
return { valid: errors.length === 0, errors };
|
|
31
80
|
}
|
package/src/output-guard.ts
CHANGED
|
@@ -79,23 +79,25 @@ export function scanFileContent(
|
|
|
79
79
|
content: Buffer,
|
|
80
80
|
): ScanResult {
|
|
81
81
|
// Skip binary files — only scan text
|
|
82
|
-
|
|
82
|
+
// Determine extension: handle files without extension (e.g., Makefile, Dockerfile)
|
|
83
|
+
const dotIndex = fileName.lastIndexOf(".");
|
|
84
|
+
const ext = dotIndex > 0 ? fileName.slice(dotIndex + 1).toLowerCase() : "";
|
|
85
|
+
const hasNoExtension = !fileName.includes(".");
|
|
86
|
+
|
|
83
87
|
const textExtensions = new Set([
|
|
84
88
|
"md", "txt", "json", "ts", "js", "py", "csv", "html", "xml",
|
|
85
89
|
"yaml", "yml", "toml", "ini", "cfg", "conf", "env", "sh",
|
|
86
90
|
"bash", "zsh", "sql", "log", "tsx", "jsx", "pem", "key",
|
|
87
91
|
"pub", "cert", "crt", "asc",
|
|
88
92
|
]);
|
|
89
|
-
|
|
93
|
+
// Skip non-text extensions (but allow files without extension like Makefile)
|
|
94
|
+
if (!hasNoExtension && !textExtensions.has(ext)) {
|
|
90
95
|
return { safe: true, findings: [] };
|
|
91
96
|
}
|
|
92
97
|
|
|
93
|
-
|
|
94
|
-
try
|
|
95
|
-
|
|
96
|
-
} catch {
|
|
97
|
-
return { safe: true, findings: [] };
|
|
98
|
-
}
|
|
98
|
+
// Buffer.toString("utf-8") never throws; it replaces invalid bytes with U+FFFD.
|
|
99
|
+
// No try/catch needed.
|
|
100
|
+
const text = content.toString("utf-8");
|
|
99
101
|
|
|
100
102
|
const findings: ScanFinding[] = [];
|
|
101
103
|
for (const { pattern, label } of SENSITIVE_PATTERNS) {
|
package/src/task-guard.ts
CHANGED
|
@@ -34,7 +34,7 @@ const RULES: GuardRule[] = [
|
|
|
34
34
|
reason: "AWS credential access",
|
|
35
35
|
},
|
|
36
36
|
{
|
|
37
|
-
pattern:
|
|
37
|
+
pattern: /(?:read|open|cat|load|access|show|get|display|查看|读取|打开|获取|显示).+\.env\b|credentials\.json|service-account.*\.json/i,
|
|
38
38
|
category: "credential_theft",
|
|
39
39
|
reason: "Secret file access",
|
|
40
40
|
},
|
package/src/task-runner.ts
CHANGED
|
@@ -1,11 +1,29 @@
|
|
|
1
1
|
import type { AgentRankClient, TaskInfo, SubmitResult } from "./agentrank-client.js";
|
|
2
2
|
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
|
3
3
|
import { mkdir, writeFile, readdir, readFile, rm } from "node:fs/promises";
|
|
4
|
-
import { join, extname } from "node:path";
|
|
4
|
+
import { join, extname, resolve } from "node:path";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { checkTask as guardCheckTask } from "./task-guard.js";
|
|
7
7
|
import { scanFileContent } from "./output-guard.js";
|
|
8
8
|
|
|
9
|
+
/** Maximum total upload size per task (500MB) */
|
|
10
|
+
const MAX_TOTAL_UPLOAD_SIZE = 500 * 1024 * 1024;
|
|
11
|
+
/** Single file upload limit */
|
|
12
|
+
const MAX_FILE_SIZE = 100 * 1024 * 1024;
|
|
13
|
+
|
|
14
|
+
/** taskId must be alphanumeric with dashes/underscores only */
|
|
15
|
+
const SAFE_TASK_ID = /^[a-zA-Z0-9_-]{1,128}$/;
|
|
16
|
+
|
|
17
|
+
/** Safely remove a task workspace directory. Only deletes paths under workspaceRoot. */
|
|
18
|
+
async function safeCleanup(workspaceDir: string, workspaceRoot: string): Promise<void> {
|
|
19
|
+
const resolvedDir = resolve(workspaceDir);
|
|
20
|
+
const resolvedRoot = resolve(workspaceRoot);
|
|
21
|
+
if (!resolvedDir.startsWith(resolvedRoot + "/") || resolvedDir === resolvedRoot) {
|
|
22
|
+
return; // Refuse to delete anything outside workspaceRoot or workspaceRoot itself
|
|
23
|
+
}
|
|
24
|
+
await rm(resolvedDir, { recursive: true, force: true });
|
|
25
|
+
}
|
|
26
|
+
|
|
9
27
|
export interface TaskRunnerOptions {
|
|
10
28
|
workspaceRoot: string;
|
|
11
29
|
taskTimeoutSeconds: number;
|
|
@@ -59,6 +77,15 @@ export class TaskRunner {
|
|
|
59
77
|
|
|
60
78
|
/** Handle a new task assignment */
|
|
61
79
|
async handleTask(task: TaskInfo): Promise<boolean> {
|
|
80
|
+
// Validate taskId format to prevent path traversal
|
|
81
|
+
if (!SAFE_TASK_ID.test(task.taskId)) {
|
|
82
|
+
this.options.logger.warn(
|
|
83
|
+
`[agentrank] Task ${task.taskId} rejected: invalid taskId format`,
|
|
84
|
+
);
|
|
85
|
+
this.client.rejectTask(task.taskId, "Invalid task ID format");
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
62
89
|
if (!this.canAcceptMore) {
|
|
63
90
|
this.options.logger.warn(
|
|
64
91
|
`[agentrank] Task ${task.taskId} rejected: max concurrent tasks reached (${this.maxConcurrent})`,
|
|
@@ -77,14 +104,29 @@ export class TaskRunner {
|
|
|
77
104
|
return false;
|
|
78
105
|
}
|
|
79
106
|
|
|
107
|
+
// Reserve the slot immediately to prevent race condition
|
|
108
|
+
const controller = new AbortController();
|
|
109
|
+
this.activeTasks.set(task.taskId, {
|
|
110
|
+
taskId: task.taskId,
|
|
111
|
+
startTime: Date.now(),
|
|
112
|
+
controller,
|
|
113
|
+
});
|
|
114
|
+
|
|
80
115
|
// Accept the task
|
|
81
116
|
this.client.acceptTask(task.taskId);
|
|
82
117
|
|
|
83
|
-
// Set up workspace
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
)
|
|
118
|
+
// Set up workspace — verify resolved path stays under workspaceRoot
|
|
119
|
+
const resolvedRoot = resolve(this.options.workspaceRoot.replace("~", homedir()));
|
|
120
|
+
const workspaceDir = join(resolvedRoot, task.taskId);
|
|
121
|
+
const resolvedDir = resolve(workspaceDir);
|
|
122
|
+
if (!resolvedDir.startsWith(resolvedRoot + "/")) {
|
|
123
|
+
this.options.logger.error(
|
|
124
|
+
`[agentrank] Task ${task.taskId} workspace escapes root: ${resolvedDir}`,
|
|
125
|
+
);
|
|
126
|
+
this.client.failTask(task.taskId, "Workspace path violation", false);
|
|
127
|
+
this.activeTasks.delete(task.taskId);
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
88
130
|
await mkdir(workspaceDir, { recursive: true });
|
|
89
131
|
|
|
90
132
|
// Write task context to workspace
|
|
@@ -103,14 +145,6 @@ export class TaskRunner {
|
|
|
103
145
|
JSON.stringify(taskContext, null, 2),
|
|
104
146
|
);
|
|
105
147
|
|
|
106
|
-
// Track the task
|
|
107
|
-
const controller = new AbortController();
|
|
108
|
-
this.activeTasks.set(task.taskId, {
|
|
109
|
-
taskId: task.taskId,
|
|
110
|
-
startTime: Date.now(),
|
|
111
|
-
controller,
|
|
112
|
-
});
|
|
113
|
-
|
|
114
148
|
// Report status busy
|
|
115
149
|
this.client.updateStatus("busy", `Working on task: ${task.title}`);
|
|
116
150
|
|
|
@@ -195,7 +229,7 @@ export class TaskRunner {
|
|
|
195
229
|
const summary = this.extractSummary(messages);
|
|
196
230
|
|
|
197
231
|
// Check if subagent refused the task (safety rules)
|
|
198
|
-
if (summary.startsWith("[REFUSED]")) {
|
|
232
|
+
if (summary.trim().startsWith("[REFUSED]")) {
|
|
199
233
|
this.options.logger.warn(
|
|
200
234
|
`[agentrank] Task ${task.taskId} refused by subagent: ${summary}`,
|
|
201
235
|
);
|
|
@@ -224,25 +258,31 @@ export class TaskRunner {
|
|
|
224
258
|
stage: "uploading",
|
|
225
259
|
});
|
|
226
260
|
|
|
227
|
-
const outputRefs = await this.uploadWorkspaceFiles(task.taskId, workspaceDir);
|
|
261
|
+
const outputRefs = await this.uploadWorkspaceFiles(task.taskId, workspaceDir, task.allowedFileTypes);
|
|
262
|
+
|
|
263
|
+
// Scan summary text for sensitive data before submitting
|
|
264
|
+
const summaryText = String(summary).slice(0, 5000);
|
|
265
|
+
const summaryScan = scanFileContent("summary.txt", Buffer.from(summaryText));
|
|
266
|
+
if (!summaryScan.safe) {
|
|
267
|
+
for (const finding of summaryScan.findings) {
|
|
268
|
+
this.options.logger.warn(
|
|
269
|
+
`[agentrank][guard] Summary BLOCKED: ${finding.label}`,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
this.client.failTask(task.taskId, "Summary contains sensitive data, blocked by output guard", false);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
228
275
|
|
|
229
276
|
// Submit result to AgentRank
|
|
230
277
|
const submitResult: SubmitResult = {
|
|
231
|
-
summary:
|
|
278
|
+
summary: summaryText,
|
|
232
279
|
outputRefs,
|
|
233
280
|
};
|
|
234
281
|
|
|
235
282
|
this.client.submitTask(task.taskId, submitResult);
|
|
236
283
|
|
|
237
|
-
// Clean up local workspace to protect task sender privacy
|
|
238
|
-
try {
|
|
239
|
-
await rm(workspaceDir, { recursive: true, force: true });
|
|
240
|
-
} catch {
|
|
241
|
-
// Non-critical: best-effort cleanup
|
|
242
|
-
}
|
|
243
|
-
|
|
244
284
|
this.options.logger.info(
|
|
245
|
-
`[agentrank] Task ${task.taskId} completed
|
|
285
|
+
`[agentrank] Task ${task.taskId} completed successfully`,
|
|
246
286
|
);
|
|
247
287
|
} catch (err) {
|
|
248
288
|
if (signal.aborted) return;
|
|
@@ -260,7 +300,7 @@ export class TaskRunner {
|
|
|
260
300
|
|
|
261
301
|
// Always clean up workspace on failure too
|
|
262
302
|
try {
|
|
263
|
-
await
|
|
303
|
+
await safeCleanup(workspaceDir, this.options.workspaceRoot.replace("~", homedir()));
|
|
264
304
|
} catch {
|
|
265
305
|
// Non-critical
|
|
266
306
|
}
|
|
@@ -317,6 +357,7 @@ export class TaskRunner {
|
|
|
317
357
|
private async uploadWorkspaceFiles(
|
|
318
358
|
taskId: string,
|
|
319
359
|
workspaceDir: string,
|
|
360
|
+
allowedFileTypes?: string[],
|
|
320
361
|
): Promise<SubmitResult["outputRefs"]> {
|
|
321
362
|
const MIME_MAP: Record<string, string> = {
|
|
322
363
|
".md": "text/markdown",
|
|
@@ -333,19 +374,55 @@ export class TaskRunner {
|
|
|
333
374
|
".pdf": "application/pdf",
|
|
334
375
|
};
|
|
335
376
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
const
|
|
377
|
+
// Recursively collect output files (skip task.json and result.json)
|
|
378
|
+
const outputFiles: string[] = [];
|
|
379
|
+
const collectFiles = async (dir: string, prefix: string = "") => {
|
|
380
|
+
const entries = await readdir(dir);
|
|
381
|
+
for (const entry of entries) {
|
|
382
|
+
const fullPath = join(dir, entry);
|
|
383
|
+
const relativePath = prefix ? `${prefix}/${entry}` : entry;
|
|
384
|
+
// Skip internal metadata files
|
|
385
|
+
if (relativePath === "task.json" || relativePath === "result.json") continue;
|
|
386
|
+
const entryStat = await lstat(fullPath);
|
|
387
|
+
if (entryStat.isDirectory()) {
|
|
388
|
+
await collectFiles(fullPath, relativePath);
|
|
389
|
+
} else {
|
|
390
|
+
outputFiles.push(relativePath);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
await collectFiles(workspaceDir);
|
|
339
395
|
|
|
340
396
|
if (outputFiles.length === 0) return [];
|
|
341
397
|
|
|
342
398
|
const refs: SubmitResult["outputRefs"] = [];
|
|
343
399
|
|
|
344
|
-
for (const
|
|
345
|
-
const filePath = join(workspaceDir,
|
|
400
|
+
for (const relativePath of outputFiles) {
|
|
401
|
+
const filePath = join(workspaceDir, relativePath);
|
|
346
402
|
try {
|
|
403
|
+
// Prevent symlink attacks: skip symlinks pointing outside workspace
|
|
404
|
+
const fileStat = await lstat(filePath);
|
|
405
|
+
if (fileStat.isSymbolicLink()) {
|
|
406
|
+
this.options.logger.warn(
|
|
407
|
+
`[agentrank] Skipping symlink: ${relativePath}`,
|
|
408
|
+
);
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
|
|
347
412
|
const content = await readFile(filePath);
|
|
348
413
|
|
|
414
|
+
// Single file upload limit: 100MB
|
|
415
|
+
const MAX_FILE_SIZE = 100 * 1024 * 1024;
|
|
416
|
+
if (content.length > MAX_FILE_SIZE) {
|
|
417
|
+
this.options.logger.warn(
|
|
418
|
+
`[agentrank] File skipped (too large: ${(content.length / 1024 / 1024).toFixed(1)}MB): ${relativePath}`,
|
|
419
|
+
);
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Use just the filename portion (after last /) for extension check
|
|
424
|
+
const fileName = relativePath.split("/").pop()!;
|
|
425
|
+
|
|
349
426
|
// Security scan: check for sensitive data before uploading
|
|
350
427
|
const scan = scanFileContent(fileName, content);
|
|
351
428
|
if (!scan.safe) {
|
|
@@ -358,6 +435,18 @@ export class TaskRunner {
|
|
|
358
435
|
}
|
|
359
436
|
|
|
360
437
|
const ext = extname(fileName).toLowerCase();
|
|
438
|
+
|
|
439
|
+
// Filter by allowed file types (from platform registration)
|
|
440
|
+
if (allowedFileTypes && allowedFileTypes.length > 0) {
|
|
441
|
+
const extNoDot = ext.slice(1); // ".md" → "md"
|
|
442
|
+
if (!allowedFileTypes.includes(extNoDot)) {
|
|
443
|
+
this.options.logger.warn(
|
|
444
|
+
`[agentrank] File skipped (not in allowed types): ${fileName}`,
|
|
445
|
+
);
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
361
450
|
const mimeType = MIME_MAP[ext] || "application/octet-stream";
|
|
362
451
|
|
|
363
452
|
this.options.logger.info(
|
|
@@ -378,7 +467,7 @@ export class TaskRunner {
|
|
|
378
467
|
);
|
|
379
468
|
} catch (err) {
|
|
380
469
|
this.options.logger.error(
|
|
381
|
-
`[agentrank] Failed to upload ${
|
|
470
|
+
`[agentrank] Failed to upload ${relativePath}: ${err instanceof Error ? err.message : String(err)}`,
|
|
382
471
|
);
|
|
383
472
|
}
|
|
384
473
|
}
|