doer-agent 0.4.2 → 0.4.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.
@@ -0,0 +1,405 @@
1
+ import path from "node:path";
2
+ import { gunzipSync, gzipSync } from "node:zlib";
3
+ import { mkdir, open, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
4
+ import { StringCodec } from "nats";
5
+ const fsRpcCodec = StringCodec();
6
+ function normalizeFsRpcPath(workspaceRoot, rawPath) {
7
+ const raw = typeof rawPath === "string" && rawPath.trim() ? rawPath.trim() : ".";
8
+ const normalizedRaw = raw.replace(/\\/g, "/");
9
+ const useAbsolute = path.isAbsolute(normalizedRaw);
10
+ const rel = normalizedRaw.replace(/^\/+/, "") || ".";
11
+ const abs = useAbsolute ? path.resolve(normalizedRaw) : path.resolve(workspaceRoot, rel);
12
+ if (!useAbsolute && abs !== workspaceRoot && !abs.startsWith(workspaceRoot + path.sep)) {
13
+ throw new Error("path escapes workspace root");
14
+ }
15
+ const formatPath = (target) => {
16
+ if (useAbsolute) {
17
+ return target.split(path.sep).join("/") || "/";
18
+ }
19
+ return path.relative(workspaceRoot, target).split(path.sep).join("/") || ".";
20
+ };
21
+ return { abs, formatPath };
22
+ }
23
+ function parseFsRpcAction(value) {
24
+ if (value === "list" ||
25
+ value === "stat" ||
26
+ value === "fetch_file" ||
27
+ value === "read_text" ||
28
+ value === "read_file" ||
29
+ value === "write_file" ||
30
+ value === "download_file" ||
31
+ value === "delete_path" ||
32
+ value === "archive_dir" ||
33
+ value === "extract_archive") {
34
+ return value;
35
+ }
36
+ throw new Error("unsupported action");
37
+ }
38
+ function normalizeFsRpcNumber(value, fallback) {
39
+ const n = Number(value);
40
+ if (!Number.isFinite(n)) {
41
+ return fallback;
42
+ }
43
+ return Math.floor(n);
44
+ }
45
+ function inferMimeType(filePath) {
46
+ const ext = path.extname(filePath).toLowerCase();
47
+ if (ext === ".txt" || ext === ".md" || ext === ".log") {
48
+ return "text/plain";
49
+ }
50
+ if (ext === ".json") {
51
+ return "application/json";
52
+ }
53
+ if (ext === ".js" || ext === ".mjs" || ext === ".cjs") {
54
+ return "text/javascript";
55
+ }
56
+ if (ext === ".ts" || ext === ".tsx") {
57
+ return "text/typescript";
58
+ }
59
+ if (ext === ".jsx") {
60
+ return "text/jsx";
61
+ }
62
+ if (ext === ".css") {
63
+ return "text/css";
64
+ }
65
+ if (ext === ".html" || ext === ".htm") {
66
+ return "text/html";
67
+ }
68
+ if (ext === ".xml") {
69
+ return "application/xml";
70
+ }
71
+ if (ext === ".svg") {
72
+ return "image/svg+xml";
73
+ }
74
+ if (ext === ".png") {
75
+ return "image/png";
76
+ }
77
+ if (ext === ".jpg" || ext === ".jpeg") {
78
+ return "image/jpeg";
79
+ }
80
+ if (ext === ".gif") {
81
+ return "image/gif";
82
+ }
83
+ if (ext === ".webp") {
84
+ return "image/webp";
85
+ }
86
+ if (ext === ".pdf") {
87
+ return "application/pdf";
88
+ }
89
+ return "application/octet-stream";
90
+ }
91
+ function normalizeArchiveRelativePath(value) {
92
+ const normalized = value.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
93
+ if (!normalized || normalized.includes("..")) {
94
+ throw new Error("invalid archive entry path");
95
+ }
96
+ return normalized;
97
+ }
98
+ async function collectDirectoryFiles(absDir, rootDir = absDir) {
99
+ const rows = await readdir(absDir, { withFileTypes: true });
100
+ const files = [];
101
+ for (const row of rows.sort((a, b) => a.name.localeCompare(b.name))) {
102
+ const child = path.join(absDir, row.name);
103
+ if (row.isDirectory()) {
104
+ files.push(...await collectDirectoryFiles(child, rootDir));
105
+ continue;
106
+ }
107
+ if (!row.isFile()) {
108
+ continue;
109
+ }
110
+ const bytes = await readFile(child);
111
+ files.push({
112
+ relPath: normalizeArchiveRelativePath(path.relative(rootDir, child)),
113
+ contentBase64: Buffer.from(bytes).toString("base64"),
114
+ sizeBytes: bytes.byteLength,
115
+ });
116
+ }
117
+ return files;
118
+ }
119
+ async function executeFsRpc(args) {
120
+ const action = parseFsRpcAction(args.request.action);
121
+ const { abs, formatPath } = normalizeFsRpcPath(args.workspaceRoot, args.request.path);
122
+ if (action === "stat") {
123
+ const entry = await stat(abs);
124
+ return {
125
+ ok: true,
126
+ action,
127
+ path: formatPath(abs),
128
+ kind: entry.isDirectory() ? "dir" : "file",
129
+ size: entry.size,
130
+ mtimeMs: entry.mtimeMs,
131
+ };
132
+ }
133
+ if (action === "list") {
134
+ const entry = await stat(abs);
135
+ if (!entry.isDirectory()) {
136
+ throw new Error("path is not a directory");
137
+ }
138
+ const limit = Math.max(1, Math.min(normalizeFsRpcNumber(args.request.limit, 200), 1000));
139
+ const rows = await readdir(abs, { withFileTypes: true });
140
+ const items = await Promise.all(rows.map(async (row) => {
141
+ const child = path.join(abs, row.name);
142
+ const childStat = await stat(child);
143
+ return {
144
+ name: row.name,
145
+ path: formatPath(child),
146
+ kind: row.isDirectory() ? "dir" : "file",
147
+ size: childStat.size,
148
+ mtimeMs: childStat.mtimeMs,
149
+ };
150
+ }));
151
+ items.sort((a, b) => (a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === "dir" ? -1 : 1));
152
+ return {
153
+ ok: true,
154
+ action,
155
+ path: formatPath(abs),
156
+ items: items.slice(0, limit),
157
+ truncated: items.length > limit,
158
+ total: items.length,
159
+ };
160
+ }
161
+ if (action === "archive_dir") {
162
+ const entry = await stat(abs);
163
+ if (!entry.isDirectory()) {
164
+ throw new Error("path is not a directory");
165
+ }
166
+ const rawArchivePath = typeof args.request.archivePath === "string" ? args.request.archivePath : "";
167
+ if (!rawArchivePath) {
168
+ throw new Error("archivePath is required");
169
+ }
170
+ const archiveTarget = normalizeFsRpcPath(args.workspaceRoot, rawArchivePath);
171
+ const files = await collectDirectoryFiles(abs);
172
+ if (!files.some((file) => file.relPath === "SKILL.md")) {
173
+ throw new Error("Selected skill directory must contain SKILL.md");
174
+ }
175
+ const payload = gzipSync(Buffer.from(JSON.stringify({ files }), "utf8"));
176
+ await mkdir(path.dirname(archiveTarget.abs), { recursive: true });
177
+ await writeFile(archiveTarget.abs, payload);
178
+ const archiveStat = await stat(archiveTarget.abs);
179
+ return {
180
+ ok: true,
181
+ action,
182
+ path: formatPath(abs),
183
+ archivePath: archiveTarget.formatPath(archiveTarget.abs),
184
+ size: archiveStat.size,
185
+ };
186
+ }
187
+ if (action === "fetch_file") {
188
+ const entry = await stat(abs);
189
+ if (!entry.isFile()) {
190
+ throw new Error("path is not a file");
191
+ }
192
+ const uploadUrl = typeof args.request.uploadUrl === "string" ? args.request.uploadUrl : "";
193
+ const agentId = typeof args.request.agentId === "string" ? args.request.agentId : "";
194
+ if (!uploadUrl || !agentId) {
195
+ throw new Error("missing upload parameters");
196
+ }
197
+ const resolvedUploadUrl = new URL(uploadUrl, `${args.serverBaseUrl}/`).toString();
198
+ const data = await readFile(abs);
199
+ const fileName = path.basename(abs) || "file";
200
+ const form = new FormData();
201
+ form.append("file", new File([data], fileName));
202
+ form.append("agentId", agentId);
203
+ const response = await fetch(resolvedUploadUrl, {
204
+ method: "POST",
205
+ headers: { Authorization: `Bearer ${args.agentToken}` },
206
+ body: form,
207
+ });
208
+ const text = await response.text();
209
+ let upload = {};
210
+ try {
211
+ upload = JSON.parse(text || "{}");
212
+ }
213
+ catch {
214
+ upload = {};
215
+ }
216
+ if (!response.ok) {
217
+ const message = typeof upload.error === "string" ? upload.error : `upload failed: ${response.status}`;
218
+ throw new Error(message);
219
+ }
220
+ return {
221
+ ok: true,
222
+ action,
223
+ path: formatPath(abs),
224
+ size: entry.size,
225
+ upload,
226
+ };
227
+ }
228
+ if (action === "write_file") {
229
+ const contentBase64 = typeof args.request.contentBase64 === "string" ? args.request.contentBase64 : "";
230
+ if (!contentBase64) {
231
+ throw new Error("contentBase64 is required");
232
+ }
233
+ const parentDir = path.dirname(abs);
234
+ await mkdir(parentDir, { recursive: true });
235
+ const bytes = Buffer.from(contentBase64, "base64");
236
+ await writeFile(abs, bytes);
237
+ const entry = await stat(abs);
238
+ return {
239
+ ok: true,
240
+ action,
241
+ path: formatPath(abs),
242
+ absolutePath: abs.split(path.sep).join("/"),
243
+ size: entry.size,
244
+ mimeType: inferMimeType(abs),
245
+ mtimeMs: entry.mtimeMs,
246
+ };
247
+ }
248
+ if (action === "delete_path") {
249
+ await rm(abs, { recursive: true, force: true });
250
+ return {
251
+ ok: true,
252
+ action,
253
+ path: formatPath(abs),
254
+ absolutePath: abs.split(path.sep).join("/"),
255
+ };
256
+ }
257
+ if (action === "download_file") {
258
+ const downloadPath = typeof args.request.downloadPath === "string" ? args.request.downloadPath.trim() : "";
259
+ if (!downloadPath) {
260
+ throw new Error("downloadPath is required");
261
+ }
262
+ const downloadUrl = new URL(downloadPath, `${args.serverBaseUrl}/`).toString();
263
+ const serverOrigin = new URL(args.serverBaseUrl).origin;
264
+ const headers = {};
265
+ if (new URL(downloadUrl).origin === serverOrigin) {
266
+ headers.Authorization = `Bearer ${args.agentToken}`;
267
+ }
268
+ const response = await fetch(downloadUrl, {
269
+ method: "GET",
270
+ headers,
271
+ });
272
+ if (!response.ok) {
273
+ const text = await response.text().catch(() => "");
274
+ throw new Error(text || `download failed: ${response.status}`);
275
+ }
276
+ const bytes = Buffer.from(await response.arrayBuffer());
277
+ const parentDir = path.dirname(abs);
278
+ await mkdir(parentDir, { recursive: true });
279
+ await writeFile(abs, bytes);
280
+ const entry = await stat(abs);
281
+ return {
282
+ ok: true,
283
+ action,
284
+ path: formatPath(abs),
285
+ absolutePath: abs.split(path.sep).join("/"),
286
+ size: entry.size,
287
+ mimeType: inferMimeType(abs),
288
+ mtimeMs: entry.mtimeMs,
289
+ };
290
+ }
291
+ if (action === "extract_archive") {
292
+ const archiveEntry = await stat(abs);
293
+ if (!archiveEntry.isFile()) {
294
+ throw new Error("path is not a file");
295
+ }
296
+ const rawDestinationPath = typeof args.request.destinationPath === "string" ? args.request.destinationPath : "";
297
+ if (!rawDestinationPath) {
298
+ throw new Error("destinationPath is required");
299
+ }
300
+ const destinationTarget = normalizeFsRpcPath(args.workspaceRoot, rawDestinationPath);
301
+ const archiveBytes = await readFile(abs);
302
+ const decoded = JSON.parse(gunzipSync(archiveBytes).toString("utf8"));
303
+ const files = Array.isArray(decoded.files) ? decoded.files : [];
304
+ await mkdir(destinationTarget.abs, { recursive: true });
305
+ for (const file of files) {
306
+ const relPath = typeof file.relPath === "string" ? normalizeArchiveRelativePath(file.relPath) : "";
307
+ const contentBase64 = typeof file.contentBase64 === "string" ? file.contentBase64 : "";
308
+ if (!relPath || !contentBase64) {
309
+ throw new Error("archive contains an invalid file entry");
310
+ }
311
+ const targetPath = path.join(destinationTarget.abs, relPath);
312
+ await mkdir(path.dirname(targetPath), { recursive: true });
313
+ await writeFile(targetPath, Buffer.from(contentBase64, "base64"));
314
+ }
315
+ return {
316
+ ok: true,
317
+ action,
318
+ path: formatPath(abs),
319
+ absolutePath: destinationTarget.formatPath(destinationTarget.abs),
320
+ };
321
+ }
322
+ const entry = await stat(abs);
323
+ if (!entry.isFile()) {
324
+ throw new Error("path is not a file");
325
+ }
326
+ if (action === "read_file") {
327
+ const maxBytes = Math.max(1, Math.min(normalizeFsRpcNumber(args.request.maxBytes, 2_000_000), 5_000_000));
328
+ const data = await readFile(abs);
329
+ const truncated = data.byteLength > maxBytes;
330
+ const bytes = truncated ? data.subarray(0, maxBytes) : data;
331
+ return {
332
+ ok: true,
333
+ action,
334
+ path: formatPath(abs),
335
+ mimeType: inferMimeType(abs),
336
+ size: entry.size,
337
+ truncated,
338
+ contentBase64: bytes.toString("base64"),
339
+ };
340
+ }
341
+ const offset = Math.max(0, normalizeFsRpcNumber(args.request.offset, 0));
342
+ const length = Math.max(1, Math.min(normalizeFsRpcNumber(args.request.length, 65536), 262144));
343
+ const encoding = typeof args.request.encoding === "string" && args.request.encoding ? args.request.encoding : "utf8";
344
+ const fd = await open(abs, "r");
345
+ try {
346
+ const buffer = Buffer.alloc(length);
347
+ const readResult = await fd.read(buffer, 0, length, offset);
348
+ const slice = buffer.subarray(0, readResult.bytesRead);
349
+ try {
350
+ const text = slice.toString(encoding);
351
+ return {
352
+ ok: true,
353
+ action,
354
+ path: formatPath(abs),
355
+ offset,
356
+ length: readResult.bytesRead,
357
+ totalSize: entry.size,
358
+ eof: offset + readResult.bytesRead >= entry.size,
359
+ encoding,
360
+ text,
361
+ bytesRead: readResult.bytesRead,
362
+ };
363
+ }
364
+ catch (error) {
365
+ const message = error instanceof Error ? error.message : "failed to decode text";
366
+ return {
367
+ ok: false,
368
+ action,
369
+ path: formatPath(abs),
370
+ error: message,
371
+ };
372
+ }
373
+ }
374
+ finally {
375
+ await fd.close();
376
+ }
377
+ }
378
+ export async function handleFsRpcMessage(args) {
379
+ let payload = {};
380
+ try {
381
+ payload = JSON.parse(fsRpcCodec.decode(args.msg.data));
382
+ if (typeof payload.agentId === "string" && payload.agentId.trim() && payload.agentId !== args.agentId) {
383
+ throw new Error("agent id mismatch");
384
+ }
385
+ const result = await executeFsRpc({
386
+ workspaceRoot: args.workspaceRoot,
387
+ request: payload,
388
+ serverBaseUrl: args.serverBaseUrl,
389
+ agentToken: args.agentToken,
390
+ });
391
+ args.msg.respond(fsRpcCodec.encode(JSON.stringify(result)));
392
+ }
393
+ catch (error) {
394
+ const message = error instanceof Error ? error.message : "unknown error";
395
+ const action = typeof payload.action === "string" ? payload.action : "";
396
+ const response = {
397
+ ok: false,
398
+ action,
399
+ path: typeof payload.path === "string" ? payload.path : ".",
400
+ error: message,
401
+ };
402
+ args.msg.respond(fsRpcCodec.encode(JSON.stringify(response)));
403
+ args.onError(`fs rpc failed action=${action || "unknown"} error=${message}`);
404
+ }
405
+ }