doer-agent 0.4.4 → 0.4.6

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.
Files changed (2) hide show
  1. package/dist/agent-fs-rpc.js +113 -80
  2. package/package.json +3 -2
@@ -1,7 +1,8 @@
1
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";
2
+ import { mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
3
+ import crypto from "node:crypto";
4
4
  import { StringCodec } from "nats";
5
+ import { create as createTar, extract as extractTar } from "tar";
5
6
  const fsRpcCodec = StringCodec();
6
7
  function normalizeFsRpcPath(workspaceRoot, rawPath) {
7
8
  const raw = typeof rawPath === "string" && rawPath.trim() ? rawPath.trim() : ".";
@@ -23,10 +24,9 @@ function normalizeFsRpcPath(workspaceRoot, rawPath) {
23
24
  function parseFsRpcAction(value) {
24
25
  if (value === "list" ||
25
26
  value === "stat" ||
26
- value === "fetch_file" ||
27
+ value === "upload_file" ||
27
28
  value === "read_text" ||
28
- value === "read_file" ||
29
- value === "write_file" ||
29
+ value === "write_text" ||
30
30
  value === "download_file" ||
31
31
  value === "delete_path" ||
32
32
  value === "archive_dir" ||
@@ -88,33 +88,20 @@ function inferMimeType(filePath) {
88
88
  }
89
89
  return "application/octet-stream";
90
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;
91
+ function sha256Hex(bytes) {
92
+ return crypto.createHash("sha256").update(bytes).digest("hex");
97
93
  }
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;
94
+ async function createTarGzipBuffer(cwd, entries) {
95
+ const stream = createTar({
96
+ cwd,
97
+ gzip: true,
98
+ portable: true,
99
+ }, entries);
100
+ const chunks = [];
101
+ for await (const chunk of stream) {
102
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
103
+ }
104
+ return Buffer.concat(chunks);
118
105
  }
119
106
  async function executeFsRpc(args) {
120
107
  const action = parseFsRpcAction(args.request.action);
@@ -168,13 +155,18 @@ async function executeFsRpc(args) {
168
155
  throw new Error("archivePath is required");
169
156
  }
170
157
  const archiveTarget = normalizeFsRpcPath(args.workspaceRoot, rawArchivePath);
171
- const files = await collectDirectoryFiles(abs);
172
- if (!files.some((file) => file.relPath === "SKILL.md")) {
158
+ try {
159
+ const manifestEntry = await stat(path.join(abs, "SKILL.md"));
160
+ if (!manifestEntry.isFile()) {
161
+ throw new Error("Selected skill directory must contain SKILL.md");
162
+ }
163
+ }
164
+ catch {
173
165
  throw new Error("Selected skill directory must contain SKILL.md");
174
166
  }
175
- const payload = gzipSync(Buffer.from(JSON.stringify({ files }), "utf8"));
176
167
  await mkdir(path.dirname(archiveTarget.abs), { recursive: true });
177
- await writeFile(archiveTarget.abs, payload);
168
+ const archiveBytes = await createTarGzipBuffer(abs, ["."]);
169
+ await writeFile(archiveTarget.abs, archiveBytes);
178
170
  const archiveStat = await stat(archiveTarget.abs);
179
171
  return {
180
172
  ok: true,
@@ -184,27 +176,58 @@ async function executeFsRpc(args) {
184
176
  size: archiveStat.size,
185
177
  };
186
178
  }
187
- if (action === "fetch_file") {
179
+ if (action === "upload_file") {
188
180
  const entry = await stat(abs);
189
181
  if (!entry.isFile()) {
190
182
  throw new Error("path is not a file");
191
183
  }
192
184
  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");
185
+ const uploadMode = args.request.uploadMode === "multipart" ? "multipart" : "raw";
186
+ const uploadMethod = args.request.uploadMethod === "POST" ? "POST" : "PUT";
187
+ const uploadContentType = typeof args.request.uploadContentType === "string" && args.request.uploadContentType.trim()
188
+ ? args.request.uploadContentType.trim()
189
+ : inferMimeType(abs);
190
+ const uploadFieldName = typeof args.request.uploadFieldName === "string" && args.request.uploadFieldName.trim()
191
+ ? args.request.uploadFieldName.trim()
192
+ : "file";
193
+ if (!uploadUrl) {
194
+ throw new Error("uploadUrl is required");
196
195
  }
197
196
  const resolvedUploadUrl = new URL(uploadUrl, `${args.serverBaseUrl}/`).toString();
198
197
  const data = await readFile(abs);
199
198
  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
- });
199
+ const serverOrigin = new URL(args.serverBaseUrl).origin;
200
+ const targetOrigin = new URL(resolvedUploadUrl).origin;
201
+ const headers = {};
202
+ if (targetOrigin === serverOrigin) {
203
+ headers.Authorization = `Bearer ${args.agentToken}`;
204
+ }
205
+ let response;
206
+ if (uploadMode === "multipart") {
207
+ const form = new FormData();
208
+ const formFields = args.request.formFields && typeof args.request.formFields === "object" && !Array.isArray(args.request.formFields)
209
+ ? args.request.formFields
210
+ : {};
211
+ for (const [key, value] of Object.entries(formFields)) {
212
+ if (typeof value === "string") {
213
+ form.append(key, value);
214
+ }
215
+ }
216
+ form.append(uploadFieldName, new File([data], fileName, { type: uploadContentType }));
217
+ response = await fetch(resolvedUploadUrl, {
218
+ method: uploadMethod,
219
+ headers,
220
+ body: form,
221
+ });
222
+ }
223
+ else {
224
+ headers["Content-Type"] = uploadContentType;
225
+ response = await fetch(resolvedUploadUrl, {
226
+ method: uploadMethod,
227
+ headers,
228
+ body: data,
229
+ });
230
+ }
208
231
  const text = await response.text();
209
232
  let upload = {};
210
233
  try {
@@ -225,15 +248,12 @@ async function executeFsRpc(args) {
225
248
  upload,
226
249
  };
227
250
  }
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
- }
251
+ if (action === "write_text") {
252
+ const text = typeof args.request.text === "string" ? args.request.text : "";
253
+ const encoding = typeof args.request.encoding === "string" && args.request.encoding ? args.request.encoding : "utf8";
233
254
  const parentDir = path.dirname(abs);
234
255
  await mkdir(parentDir, { recursive: true });
235
- const bytes = Buffer.from(contentBase64, "base64");
236
- await writeFile(abs, bytes);
256
+ await writeFile(abs, text, { encoding: encoding });
237
257
  const entry = await stat(abs);
238
258
  return {
239
259
  ok: true,
@@ -243,6 +263,7 @@ async function executeFsRpc(args) {
243
263
  size: entry.size,
244
264
  mimeType: inferMimeType(abs),
245
265
  mtimeMs: entry.mtimeMs,
266
+ encoding,
246
267
  };
247
268
  }
248
269
  if (action === "delete_path") {
@@ -277,6 +298,9 @@ async function executeFsRpc(args) {
277
298
  const parentDir = path.dirname(abs);
278
299
  await mkdir(parentDir, { recursive: true });
279
300
  await writeFile(abs, bytes);
301
+ if (abs.endsWith(".skillpkg")) {
302
+ console.log(`[doer-agent] skillpkg downloaded path=${formatPath(abs)} size=${bytes.byteLength} sha256=${sha256Hex(bytes)}`);
303
+ }
280
304
  const entry = await stat(abs);
281
305
  return {
282
306
  ok: true,
@@ -299,18 +323,42 @@ async function executeFsRpc(args) {
299
323
  }
300
324
  const destinationTarget = normalizeFsRpcPath(args.workspaceRoot, rawDestinationPath);
301
325
  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");
326
+ const magic = archiveBytes.subarray(0, 8).toString("hex");
327
+ const digest = sha256Hex(archiveBytes);
328
+ const destinationParent = path.dirname(destinationTarget.abs);
329
+ const tempDestinationAbs = path.join(destinationParent, `.tmp-extract-${path.basename(destinationTarget.abs)}-${crypto.randomBytes(6).toString("hex")}`);
330
+ await mkdir(destinationParent, { recursive: true });
331
+ try {
332
+ const existing = await stat(destinationTarget.abs);
333
+ if (existing.isDirectory()) {
334
+ const entries = await readdir(destinationTarget.abs);
335
+ if (entries.length > 0) {
336
+ throw new Error("destinationPath already exists");
337
+ }
338
+ }
339
+ else {
340
+ throw new Error("destinationPath already exists");
341
+ }
342
+ await rm(destinationTarget.abs, { recursive: true, force: true });
343
+ }
344
+ catch (error) {
345
+ if (!(error instanceof Error) || !error.message.includes("ENOENT")) {
346
+ throw error;
310
347
  }
311
- const targetPath = path.join(destinationTarget.abs, relPath);
312
- await mkdir(path.dirname(targetPath), { recursive: true });
313
- await writeFile(targetPath, Buffer.from(contentBase64, "base64"));
348
+ }
349
+ await mkdir(tempDestinationAbs, { recursive: true });
350
+ try {
351
+ await extractTar({
352
+ cwd: tempDestinationAbs,
353
+ file: abs,
354
+ gzip: true,
355
+ });
356
+ await rename(tempDestinationAbs, destinationTarget.abs);
357
+ }
358
+ catch (error) {
359
+ await rm(tempDestinationAbs, { recursive: true, force: true }).catch(() => undefined);
360
+ const message = error instanceof Error ? error.message : "extract failed";
361
+ throw new Error(`${message} (magic=${magic} size=${archiveBytes.byteLength} sha256=${digest})`);
314
362
  }
315
363
  return {
316
364
  ok: true,
@@ -323,21 +371,6 @@ async function executeFsRpc(args) {
323
371
  if (!entry.isFile()) {
324
372
  throw new Error("path is not a file");
325
373
  }
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
374
  const offset = Math.max(0, normalizeFsRpcNumber(args.request.offset, 0));
342
375
  const length = Math.max(1, Math.min(normalizeFsRpcNumber(args.request.length, 65536), 262144));
343
376
  const encoding = typeof args.request.encoding === "string" && args.request.encoding ? args.request.encoding : "utf8";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doer-agent",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "description": "Reverse-polling agent runtime for doer",
5
5
  "type": "module",
6
6
  "main": "dist/agent.js",
@@ -26,7 +26,8 @@
26
26
  "dependencies": {
27
27
  "@modelcontextprotocol/sdk": "^1.27.1",
28
28
  "@openai/codex-sdk": "^0.115.0",
29
- "nats": "^2.29.3"
29
+ "nats": "^2.29.3",
30
+ "tar": "^7.5.13"
30
31
  },
31
32
  "devDependencies": {
32
33
  "@types/node": "^20",