@lingda_ai/agentrank 0.1.2 → 0.1.3

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 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
- client = new AgentRankClient({
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.on("error", (err: unknown) => {
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
- client!.acceptTask(params.taskId);
230
- return json({ accepted: true, taskId: params.taskId });
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
- // Trigger the start gateway method logic
329
- client = new AgentRankClient({
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.2",
3
+ "version": "0.1.3",
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": ["./index.ts"],
14
+ "extensions": [
15
+ "./index.ts"
16
+ ],
15
17
  "compat": {
16
18
  "pluginApi": ">=2026.3.24",
17
19
  "minGatewayVersion": "2026.3.24"
@@ -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,35 @@ 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
- const url = `${wsUrl}/ws/agent?key=***&device=${this.options.deviceId}`;
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?key=${this.options.apiKey}&device=${this.options.deviceId}`);
91
+ this.ws = new WebSocket(`${wsUrl}/ws/agent?device=${this.options.deviceId}`, {
92
+ maxPayload: 1024 * 1024, // 1MB message size limit
93
+ });
94
+
95
+ this.ws.on("upgrade", () => {
96
+ // Send auth as first message instead of URL query to avoid key leaking in logs
97
+ this.sendAuth();
98
+ });
90
99
 
91
100
  this.ws.onopen = () => {
92
101
  this._connected = true;
102
+ this.reconnectAttempts = 0; // Reset backoff on successful connect
93
103
  this.startHeartbeat();
94
104
  this.log("[agentrank][ws] connected");
95
105
  this.emit("connected");
96
106
  resolve();
97
107
  };
98
108
 
109
+ const MAX_MESSAGE_SIZE = 1024 * 1024; // 1MB
99
110
  this.ws.onmessage = (event) => {
100
111
  try {
101
- const msg = JSON.parse(event.data as string) as { type: string; payload: unknown };
112
+ const raw = event.data as string;
113
+ if (raw.length > MAX_MESSAGE_SIZE) {
114
+ this.log("[agentrank][ws] message too large (%d bytes), dropping", raw.length);
115
+ return;
116
+ }
117
+ const msg = JSON.parse(raw) as { type: string; payload: unknown };
102
118
  this.handleMessage(msg);
103
119
  } catch {
104
120
  this.log("[agentrank][ws] received malformed message");
@@ -238,6 +254,13 @@ export class AgentRankClient extends Emitter {
238
254
 
239
255
  // ---- Internal ----
240
256
 
257
+ private sendAuth() {
258
+ this.send("auth", {
259
+ key: this.options.apiKey,
260
+ device: this.options.deviceId,
261
+ });
262
+ }
263
+
241
264
  private send(type: string, payload: unknown) {
242
265
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
243
266
  this.log("[agentrank][ws] send dropped (not connected): %s", type);
@@ -267,7 +290,7 @@ export class AgentRankClient extends Emitter {
267
290
  private startHeartbeat() {
268
291
  this.heartbeatTimer = setInterval(() => {
269
292
  if (this.ws?.readyState === WebSocket.OPEN) {
270
- this.ws.send(JSON.stringify({ type: "pong", payload: {}, ts: Date.now() }));
293
+ this.ws.send(JSON.stringify({ type: "ping", payload: {}, ts: Date.now() }));
271
294
  }
272
295
  }, 30_000);
273
296
  this.log("[agentrank][ws] heartbeat started (30s interval)");
@@ -283,13 +306,16 @@ export class AgentRankClient extends Emitter {
283
306
 
284
307
  private scheduleReconnect() {
285
308
  if (this._stopped) return;
286
- this.log("[agentrank][ws] reconnecting in 5s...");
309
+ // Exponential backoff: 5s, 10s, 20s, 40s... capped at 5min
310
+ const delay = Math.min(5000 * Math.pow(2, this.reconnectAttempts), 300_000);
311
+ this.reconnectAttempts++;
312
+ this.log("[agentrank][ws] reconnecting in %dms (attempt %d)...", delay, this.reconnectAttempts);
287
313
  this.reconnectTimer = setTimeout(async () => {
288
314
  try {
289
315
  await this.connect();
290
316
  } catch {
291
317
  // scheduleReconnect will be triggered by onclose again
292
318
  }
293
- }, 5000);
319
+ }, delay);
294
320
  }
295
321
  }
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
- export const AgentRankConfigSchema = z.object({
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("~/.agentrank/workspace"),
50
+ workspaceRoot: z.string().default(join(tmpdir(), "agentrank-tasks")),
12
51
  });
13
- export type AgentRankConfig = z.infer<typeof AgentRankConfigSchema>;
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
- return AgentRankConfigSchema.parse(input);
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
  }
@@ -79,23 +79,25 @@ export function scanFileContent(
79
79
  content: Buffer,
80
80
  ): ScanResult {
81
81
  // Skip binary files — only scan text
82
- const ext = fileName.split(".").pop()?.toLowerCase() ?? "";
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
- if (!textExtensions.has(ext) && ext !== "") {
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
- let text: string;
94
- try {
95
- text = content.toString("utf-8");
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: /\.env\b|credentials\.json|service-account.*\.json/i,
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
  },
@@ -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 workspaceDir = join(
85
- this.options.workspaceRoot.replace("~", homedir()),
86
- task.taskId,
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: String(summary).slice(0, 5000),
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 and workspace cleaned`,
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 rm(workspaceDir, { recursive: true, force: true });
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
- const entries = await readdir(workspaceDir);
337
- // Skip task.json and result.json (internal metadata)
338
- const outputFiles = entries.filter((f) => f !== "task.json" && f !== "result.json");
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 fileName of outputFiles) {
345
- const filePath = join(workspaceDir, fileName);
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 ${fileName}: ${err instanceof Error ? err.message : String(err)}`,
470
+ `[agentrank] Failed to upload ${relativePath}: ${err instanceof Error ? err.message : String(err)}`,
382
471
  );
383
472
  }
384
473
  }