@nkmc/agent-fs 0.1.0

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 (42) hide show
  1. package/dist/chunk-7LIZT7L3.js +966 -0
  2. package/dist/index.cjs +1278 -0
  3. package/dist/index.d.cts +96 -0
  4. package/dist/index.d.ts +96 -0
  5. package/dist/index.js +419 -0
  6. package/dist/rpc-D1IHpjF_.d.cts +330 -0
  7. package/dist/rpc-D1IHpjF_.d.ts +330 -0
  8. package/dist/testing.cjs +842 -0
  9. package/dist/testing.d.cts +29 -0
  10. package/dist/testing.d.ts +29 -0
  11. package/dist/testing.js +10 -0
  12. package/package.json +25 -0
  13. package/src/agent-fs.ts +151 -0
  14. package/src/backends/http.ts +835 -0
  15. package/src/backends/memory.ts +183 -0
  16. package/src/backends/rpc.ts +456 -0
  17. package/src/index.ts +36 -0
  18. package/src/mount.ts +84 -0
  19. package/src/parser.ts +162 -0
  20. package/src/server.ts +158 -0
  21. package/src/testing.ts +3 -0
  22. package/src/types.ts +52 -0
  23. package/test/agent-fs.test.ts +325 -0
  24. package/test/http-204.test.ts +102 -0
  25. package/test/http-auth-prefix.test.ts +79 -0
  26. package/test/http-cloudflare.test.ts +533 -0
  27. package/test/http-form-encoding.test.ts +119 -0
  28. package/test/http-github.test.ts +580 -0
  29. package/test/http-listkey.test.ts +128 -0
  30. package/test/http-oauth2.test.ts +174 -0
  31. package/test/http-pagination.test.ts +200 -0
  32. package/test/http-param-styles.test.ts +98 -0
  33. package/test/http-passthrough.test.ts +282 -0
  34. package/test/http-retry.test.ts +132 -0
  35. package/test/http.test.ts +360 -0
  36. package/test/memory.test.ts +120 -0
  37. package/test/mount.test.ts +94 -0
  38. package/test/parser.test.ts +100 -0
  39. package/test/rpc-crud.test.ts +627 -0
  40. package/test/rpc-evm.test.ts +390 -0
  41. package/tsconfig.json +8 -0
  42. package/tsup.config.ts +8 -0
@@ -0,0 +1,325 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { AgentFs } from "../src/agent-fs.js";
3
+ import { MemoryBackend } from "../src/backends/memory.js";
4
+ import type { FsCommand } from "../src/types.js";
5
+
6
+ describe("AgentFs", () => {
7
+ let fs: AgentFs;
8
+ let backend: MemoryBackend;
9
+
10
+ beforeEach(() => {
11
+ backend = new MemoryBackend();
12
+ backend.seed("users", [
13
+ { id: "1", name: "Alice", status: "active" },
14
+ { id: "2", name: "Bob", status: "inactive" },
15
+ ]);
16
+ backend.seed("products", [
17
+ { id: "1", name: "Widget", price: 9.99 },
18
+ ]);
19
+
20
+ fs = new AgentFs({
21
+ mounts: [{ path: "/db", backend }],
22
+ });
23
+ });
24
+
25
+ describe("execute (string commands)", () => {
26
+ it("should execute ls /", async () => {
27
+ const result = await fs.execute("ls /");
28
+ expect(result.ok).toBe(true);
29
+ if (result.ok) {
30
+ expect(result.data).toEqual(["db/"]);
31
+ }
32
+ });
33
+
34
+ it("should execute ls /db/", async () => {
35
+ const result = await fs.execute("ls /db/");
36
+ expect(result.ok).toBe(true);
37
+ if (result.ok) {
38
+ const entries = result.data as string[];
39
+ expect(entries).toContain("users/");
40
+ expect(entries).toContain("products/");
41
+ }
42
+ });
43
+
44
+ it("should execute ls /db/users/", async () => {
45
+ const result = await fs.execute("ls /db/users/");
46
+ expect(result.ok).toBe(true);
47
+ if (result.ok) {
48
+ const entries = result.data as string[];
49
+ expect(entries).toContain("1.json");
50
+ expect(entries).toContain("2.json");
51
+ }
52
+ });
53
+
54
+ it("should execute cat /db/users/1.json", async () => {
55
+ const result = await fs.execute("cat /db/users/1.json");
56
+ expect(result.ok).toBe(true);
57
+ if (result.ok) {
58
+ expect(result.data).toEqual({ id: "1", name: "Alice", status: "active" });
59
+ }
60
+ });
61
+
62
+ it("should execute write to create", async () => {
63
+ const result = await fs.execute(
64
+ 'write /db/users/ \'{"name":"Charlie","status":"active"}\'',
65
+ );
66
+ expect(result.ok).toBe(true);
67
+ if (result.ok) {
68
+ const { id } = result.data as { id: string };
69
+ const read = await fs.execute(`cat /db/users/${id}.json`);
70
+ expect(read.ok).toBe(true);
71
+ if (read.ok) {
72
+ expect((read.data as { name: string }).name).toBe("Charlie");
73
+ }
74
+ }
75
+ });
76
+
77
+ it("should execute write to update", async () => {
78
+ const result = await fs.execute(
79
+ 'write /db/users/1.json \'{"name":"Alice Updated"}\'',
80
+ );
81
+ expect(result.ok).toBe(true);
82
+
83
+ const read = await fs.execute("cat /db/users/1.json");
84
+ expect(read.ok).toBe(true);
85
+ if (read.ok) {
86
+ expect((read.data as { name: string }).name).toBe("Alice Updated");
87
+ }
88
+ });
89
+
90
+ it("should execute rm", async () => {
91
+ const result = await fs.execute("rm /db/users/2.json");
92
+ expect(result.ok).toBe(true);
93
+
94
+ const list = await fs.execute("ls /db/users/");
95
+ expect(list.ok).toBe(true);
96
+ if (list.ok) {
97
+ expect(list.data).not.toContain("2.json");
98
+ }
99
+ });
100
+
101
+ it("should execute grep", async () => {
102
+ const result = await fs.execute('grep "Alice" /db/users/');
103
+ expect(result.ok).toBe(true);
104
+ if (result.ok) {
105
+ const matches = result.data as { name: string }[];
106
+ expect(matches).toHaveLength(1);
107
+ expect(matches[0].name).toBe("Alice");
108
+ }
109
+ });
110
+
111
+ it("should handle nk prefix", async () => {
112
+ const result = await fs.execute("nk ls /db/users/");
113
+ expect(result.ok).toBe(true);
114
+ });
115
+
116
+ it("should return NOT_FOUND for missing record", async () => {
117
+ const result = await fs.execute("cat /db/users/999.json");
118
+ expect(result.ok).toBe(false);
119
+ if (!result.ok) {
120
+ expect(result.error.code).toBe("NOT_FOUND");
121
+ }
122
+ });
123
+
124
+ it("should return NO_MOUNT for unknown path", async () => {
125
+ const result = await fs.execute("ls /unknown/");
126
+ expect(result.ok).toBe(false);
127
+ if (!result.ok) {
128
+ expect(result.error.code).toBe("NO_MOUNT");
129
+ }
130
+ });
131
+ });
132
+
133
+ describe("permissions", () => {
134
+ it("should deny access when role is insufficient", async () => {
135
+ const restrictedFs = new AgentFs({
136
+ mounts: [
137
+ {
138
+ path: "/db",
139
+ backend,
140
+ permissions: { read: ["premium"], write: ["admin"] },
141
+ },
142
+ ],
143
+ });
144
+
145
+ const result = await restrictedFs.execute("ls /db/users/", ["agent"]);
146
+ expect(result.ok).toBe(false);
147
+ if (!result.ok) {
148
+ expect(result.error.code).toBe("PERMISSION_DENIED");
149
+ }
150
+ });
151
+
152
+ it("should allow access with correct role", async () => {
153
+ const restrictedFs = new AgentFs({
154
+ mounts: [
155
+ {
156
+ path: "/db",
157
+ backend,
158
+ permissions: { read: ["premium"], write: ["admin"] },
159
+ },
160
+ ],
161
+ });
162
+
163
+ const result = await restrictedFs.execute("ls /db/users/", ["premium"]);
164
+ expect(result.ok).toBe(true);
165
+ });
166
+
167
+ it("should check write permission for rm", async () => {
168
+ const restrictedFs = new AgentFs({
169
+ mounts: [
170
+ {
171
+ path: "/db",
172
+ backend,
173
+ permissions: { read: ["agent"], write: ["admin"] },
174
+ },
175
+ ],
176
+ });
177
+
178
+ const result = await restrictedFs.execute("rm /db/users/1.json", [
179
+ "agent",
180
+ ]);
181
+ expect(result.ok).toBe(false);
182
+ if (!result.ok) {
183
+ expect(result.error.code).toBe("PERMISSION_DENIED");
184
+ }
185
+ });
186
+ });
187
+
188
+ describe("searchEndpoints hook", () => {
189
+ it("should use searchEndpoints for domain-level grep", async () => {
190
+ const mockEndpoints = [
191
+ { method: "GET", path: "/alerts/active", description: "Active alerts" },
192
+ { method: "GET", path: "/alerts/{id}", description: "Single alert" },
193
+ ];
194
+ const searchEndpoints = async (_domain: string, _query: string) => mockEndpoints;
195
+
196
+ const fsWithHook = new AgentFs({
197
+ mounts: [{ path: "/db", backend }],
198
+ searchEndpoints,
199
+ });
200
+
201
+ const result = await fsWithHook.execute('grep "alerts" /api.weather.gov/');
202
+ expect(result.ok).toBe(true);
203
+ if (result.ok) {
204
+ expect(result.data).toEqual(mockEndpoints);
205
+ }
206
+ });
207
+
208
+ it("should pass correct domain and query to searchEndpoints", async () => {
209
+ let capturedDomain = "";
210
+ let capturedQuery = "";
211
+ const searchEndpoints = async (domain: string, query: string) => {
212
+ capturedDomain = domain;
213
+ capturedQuery = query;
214
+ return [];
215
+ };
216
+
217
+ const fsWithHook = new AgentFs({
218
+ mounts: [{ path: "/db", backend }],
219
+ searchEndpoints,
220
+ });
221
+
222
+ await fsWithHook.execute('grep "weather" /api.example.com/');
223
+ expect(capturedDomain).toBe("api.example.com");
224
+ expect(capturedQuery).toBe("weather");
225
+ });
226
+
227
+ it("should not use searchEndpoints for deep paths", async () => {
228
+ let hookCalled = false;
229
+ const searchEndpoints = async () => {
230
+ hookCalled = true;
231
+ return [];
232
+ };
233
+
234
+ const fsWithHook = new AgentFs({
235
+ mounts: [{ path: "/db", backend }],
236
+ searchEndpoints,
237
+ });
238
+
239
+ // Deep path like /db/users/ should go to backend.search(), not searchEndpoints
240
+ const result = await fsWithHook.execute('grep "Alice" /db/users/');
241
+ expect(result.ok).toBe(true);
242
+ expect(hookCalled).toBe(false);
243
+ });
244
+
245
+ it("should still use searchDomains for root grep", async () => {
246
+ const mockDomainResults = [{ domain: "test.com", name: "Test" }];
247
+ const searchDomains = async () => mockDomainResults;
248
+ let endpointHookCalled = false;
249
+ const searchEndpoints = async () => {
250
+ endpointHookCalled = true;
251
+ return [];
252
+ };
253
+
254
+ const fsWithHook = new AgentFs({
255
+ mounts: [{ path: "/db", backend }],
256
+ searchDomains,
257
+ searchEndpoints,
258
+ });
259
+
260
+ const result = await fsWithHook.execute('grep "test" /');
261
+ expect(result.ok).toBe(true);
262
+ if (result.ok) {
263
+ expect(result.data).toEqual(mockDomainResults);
264
+ }
265
+ expect(endpointHookCalled).toBe(false);
266
+ });
267
+
268
+ it("should return empty array when searchEndpoints returns nothing", async () => {
269
+ const searchEndpoints = async () => [];
270
+ const fsWithHook = new AgentFs({
271
+ mounts: [{ path: "/db", backend }],
272
+ searchEndpoints,
273
+ });
274
+
275
+ const result = await fsWithHook.execute('grep "zzz" /some-domain.com/');
276
+ expect(result.ok).toBe(true);
277
+ if (result.ok) {
278
+ expect(result.data).toEqual([]);
279
+ }
280
+ });
281
+ });
282
+
283
+ describe("multiple mounts", () => {
284
+ it("should route to correct backend", async () => {
285
+ const kvBackend = new MemoryBackend();
286
+ kvBackend.seed("session", [{ id: "abc", token: "xyz" }]);
287
+
288
+ const multiFs = new AgentFs({
289
+ mounts: [
290
+ { path: "/db", backend },
291
+ { path: "/kv", backend: kvBackend },
292
+ ],
293
+ });
294
+
295
+ const dbResult = await multiFs.execute("cat /db/users/1.json");
296
+ expect(dbResult.ok).toBe(true);
297
+ if (dbResult.ok) {
298
+ expect((dbResult.data as { name: string }).name).toBe("Alice");
299
+ }
300
+
301
+ const kvResult = await multiFs.execute("cat /kv/session/abc.json");
302
+ expect(kvResult.ok).toBe(true);
303
+ if (kvResult.ok) {
304
+ expect((kvResult.data as { token: string }).token).toBe("xyz");
305
+ }
306
+ });
307
+
308
+ it("should list all mounts at root", async () => {
309
+ const multiFs = new AgentFs({
310
+ mounts: [
311
+ { path: "/db", backend },
312
+ { path: "/kv", backend: new MemoryBackend() },
313
+ ],
314
+ });
315
+
316
+ const result = await multiFs.execute("ls /");
317
+ expect(result.ok).toBe(true);
318
+ if (result.ok) {
319
+ const entries = result.data as string[];
320
+ expect(entries).toContain("db/");
321
+ expect(entries).toContain("kv/");
322
+ }
323
+ });
324
+ });
325
+ });
@@ -0,0 +1,102 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
2
+ import { createServer, type Server, type IncomingMessage, type ServerResponse } from "node:http";
3
+ import { HttpBackend } from "../src/backends/http.js";
4
+ import { safeJson } from "../src/backends/http.js";
5
+
6
+ describe("safeJson", () => {
7
+ it("should return null for 204 No Content", async () => {
8
+ const resp = new Response(null, { status: 204 });
9
+ expect(await safeJson(resp)).toBeNull();
10
+ });
11
+
12
+ it("should return null for empty body", async () => {
13
+ const resp = new Response("", { status: 200 });
14
+ expect(await safeJson(resp)).toBeNull();
15
+ });
16
+
17
+ it("should return null for whitespace-only body", async () => {
18
+ const resp = new Response(" \n ", { status: 200 });
19
+ expect(await safeJson(resp)).toBeNull();
20
+ });
21
+
22
+ it("should parse valid JSON", async () => {
23
+ const resp = new Response('{"id":1}', { status: 200 });
24
+ expect(await safeJson(resp)).toEqual({ id: 1 });
25
+ });
26
+
27
+ it("should return null for invalid JSON", async () => {
28
+ const resp = new Response("<html>error</html>", { status: 200 });
29
+ expect(await safeJson(resp)).toBeNull();
30
+ });
31
+ });
32
+
33
+ describe("HttpBackend 204 handling (mock server)", () => {
34
+ let server: Server;
35
+ let port: number;
36
+
37
+ beforeAll(async () => {
38
+ await new Promise<void>((resolve) => {
39
+ server = createServer((req: IncomingMessage, res: ServerResponse) => {
40
+ const url = new URL(req.url ?? "/", "http://localhost");
41
+
42
+ // DELETE /items/{id} → 204 No Content (like DigitalOcean)
43
+ if (req.method === "DELETE" && url.pathname.startsWith("/items/")) {
44
+ res.writeHead(204);
45
+ res.end();
46
+ return;
47
+ }
48
+
49
+ // PUT /items/{id} → 200 with empty body (edge case)
50
+ if (req.method === "PUT" && url.pathname.startsWith("/items/")) {
51
+ res.writeHead(200);
52
+ res.end();
53
+ return;
54
+ }
55
+
56
+ // GET /items → list
57
+ if (req.method === "GET" && url.pathname === "/items") {
58
+ res.writeHead(200, { "Content-Type": "application/json" });
59
+ res.end(JSON.stringify([{ id: "1", name: "A" }]));
60
+ return;
61
+ }
62
+
63
+ // GET /items/{id} → single item
64
+ if (req.method === "GET" && url.pathname.startsWith("/items/")) {
65
+ res.writeHead(200, { "Content-Type": "application/json" });
66
+ res.end(JSON.stringify({ id: "1", name: "A" }));
67
+ return;
68
+ }
69
+
70
+ res.writeHead(404);
71
+ res.end();
72
+ });
73
+ server.listen(0, () => {
74
+ port = (server.address() as any).port;
75
+ resolve();
76
+ });
77
+ });
78
+ });
79
+
80
+ afterAll(() => server?.close());
81
+
82
+ it("DELETE returning 204 should not crash", async () => {
83
+ const backend = new HttpBackend({
84
+ baseUrl: `http://localhost:${port}`,
85
+ resources: [{ name: "items", apiPath: "/items" }],
86
+ });
87
+
88
+ // This used to crash with JSON parse error on empty body
89
+ await expect(backend.remove("/items/1.json")).resolves.not.toThrow();
90
+ });
91
+
92
+ it("PUT returning empty 200 should not crash", async () => {
93
+ const backend = new HttpBackend({
94
+ baseUrl: `http://localhost:${port}`,
95
+ resources: [{ name: "items", apiPath: "/items" }],
96
+ });
97
+
98
+ // write (update) to /items/1 — server returns empty 200
99
+ const result = await backend.write("/items/1.json", { name: "B" });
100
+ expect(result.id).toBe("1"); // Falls back to the path ID
101
+ });
102
+ });
@@ -0,0 +1,79 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { HttpBackend } from "../src/backends/http.js";
3
+
4
+ describe("HttpBackend auth prefix", () => {
5
+ function createBackendWithAuth(auth: any): { backend: HttpBackend; getHeaders: () => Record<string, string> } {
6
+ let capturedHeaders: Record<string, string> = {};
7
+
8
+ const mockFetch = async (_url: string, init: RequestInit) => {
9
+ capturedHeaders = Object.fromEntries(
10
+ Object.entries(init.headers as Record<string, string>),
11
+ );
12
+ return new Response(JSON.stringify({ ok: true }), { status: 200 });
13
+ };
14
+
15
+ // No resources/endpoints → passthrough mode, so read("/test") triggers HTTP
16
+ const backend = new HttpBackend({
17
+ baseUrl: "https://api.example.com",
18
+ auth,
19
+ fetch: mockFetch as any,
20
+ });
21
+
22
+ return { backend, getHeaders: () => capturedHeaders };
23
+ }
24
+
25
+ it("should default to 'Bearer' prefix for bearer auth", async () => {
26
+ const { backend, getHeaders } = createBackendWithAuth({
27
+ type: "bearer",
28
+ token: "test-token",
29
+ });
30
+
31
+ await backend.read("/test");
32
+ expect(getHeaders()["Authorization"]).toBe("Bearer test-token");
33
+ });
34
+
35
+ it("should use custom 'Bot' prefix for Discord-style auth", async () => {
36
+ const { backend, getHeaders } = createBackendWithAuth({
37
+ type: "bearer",
38
+ token: "discord-bot-token",
39
+ prefix: "Bot",
40
+ });
41
+
42
+ await backend.read("/test");
43
+ expect(getHeaders()["Authorization"]).toBe("Bot discord-bot-token");
44
+ });
45
+
46
+ it("should use custom 'Token' prefix", async () => {
47
+ const { backend, getHeaders } = createBackendWithAuth({
48
+ type: "bearer",
49
+ token: "my-token",
50
+ prefix: "Token",
51
+ });
52
+
53
+ await backend.read("/test");
54
+ expect(getHeaders()["Authorization"]).toBe("Token my-token");
55
+ });
56
+
57
+ it("should not affect api-key auth", async () => {
58
+ const { backend, getHeaders } = createBackendWithAuth({
59
+ type: "api-key",
60
+ header: "X-API-Key",
61
+ key: "secret-key",
62
+ });
63
+
64
+ await backend.read("/test");
65
+ expect(getHeaders()["X-API-Key"]).toBe("secret-key");
66
+ expect(getHeaders()["Authorization"]).toBeUndefined();
67
+ });
68
+
69
+ it("should not affect basic auth", async () => {
70
+ const { backend, getHeaders } = createBackendWithAuth({
71
+ type: "basic",
72
+ username: "user",
73
+ password: "pass",
74
+ });
75
+
76
+ await backend.read("/test");
77
+ expect(getHeaders()["Authorization"]).toBe(`Basic ${btoa("user:pass")}`);
78
+ });
79
+ });