@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
package/src/mount.ts ADDED
@@ -0,0 +1,84 @@
1
+ import type { AgentContext, FsBackend, FsError, Mount } from "./types.js";
2
+
3
+ export interface ResolvedMount {
4
+ mount: Mount;
5
+ /** The path relative to the mount point (e.g. "/users/42.json") */
6
+ relativePath: string;
7
+ }
8
+
9
+ export class MountResolver {
10
+ private mounts: Mount[] = [];
11
+
12
+ /** Optional async callback invoked when resolve() finds no matching mount.
13
+ * Should return true if a new mount was added. */
14
+ onMiss?: (path: string, agent?: AgentContext) => Promise<boolean>;
15
+
16
+ /** Register a mount point */
17
+ add(mount: Mount): void {
18
+ // Ensure path starts with / and has no trailing slash
19
+ const normalized = mount.path.replace(/\/+$/, "") || "/";
20
+ this.mounts.push({ ...mount, path: normalized });
21
+ // Sort by path length descending so longer (more specific) mounts match first
22
+ this.mounts.sort((a, b) => b.path.length - a.path.length);
23
+ }
24
+
25
+ /** Resolve a virtual path to a mount and its relative path */
26
+ resolve(virtualPath: string): ResolvedMount | null {
27
+ for (const mount of this.mounts) {
28
+ if (
29
+ virtualPath === mount.path ||
30
+ virtualPath.startsWith(mount.path + "/")
31
+ ) {
32
+ const relativePath = virtualPath.slice(mount.path.length) || "/";
33
+ return { mount, relativePath };
34
+ }
35
+ }
36
+ return null;
37
+ }
38
+
39
+ /** Async resolve: tries sync resolve first, then calls onMiss if set. */
40
+ async resolveAsync(virtualPath: string, agent?: AgentContext): Promise<ResolvedMount | null> {
41
+ const result = this.resolve(virtualPath);
42
+ if (result) return result;
43
+
44
+ if (this.onMiss) {
45
+ const added = await this.onMiss(virtualPath, agent);
46
+ if (added) {
47
+ return this.resolve(virtualPath);
48
+ }
49
+ }
50
+
51
+ return null;
52
+ }
53
+
54
+ /** Check if a role has permission for an operation on a mount */
55
+ checkPermission(
56
+ mount: Mount,
57
+ op: "read" | "write",
58
+ roles: string[],
59
+ ): FsError | null {
60
+ const allowed = mount.permissions?.[op];
61
+ // No permissions defined = open access
62
+ if (!allowed) return null;
63
+
64
+ const hasRole = roles.some((r) => allowed.includes(r));
65
+ if (!hasRole) {
66
+ return {
67
+ code: "PERMISSION_DENIED",
68
+ message: `Requires one of [${allowed.join(", ")}] for ${op} on ${mount.path}`,
69
+ };
70
+ }
71
+ return null;
72
+ }
73
+
74
+ /** List all registered mount paths (used by "ls /") */
75
+ listMounts(): string[] {
76
+ return this.mounts.map((m) => m.path);
77
+ }
78
+
79
+ /** Get the backend for a mount path */
80
+ getBackend(mountPath: string): FsBackend | undefined {
81
+ const mount = this.mounts.find((m) => m.path === mountPath);
82
+ return mount?.backend;
83
+ }
84
+ }
package/src/parser.ts ADDED
@@ -0,0 +1,162 @@
1
+ import type { FsCommand, FsOp, FsResult } from "./types.js";
2
+
3
+ const VALID_OPS = new Set<FsOp>(["ls", "cat", "write", "rm", "grep"]);
4
+
5
+ /**
6
+ * Parse a raw command string into a structured FsCommand.
7
+ *
8
+ * Accepted formats:
9
+ * ls /path
10
+ * cat /path
11
+ * write /path '{"key":"value"}'
12
+ * write /path {"key":"value"}
13
+ * rm /path
14
+ * grep pattern /path
15
+ */
16
+ export function parseCommand(input: string): FsResult {
17
+ const trimmed = input.trim();
18
+
19
+ // Strip optional "nk " prefix
20
+ const normalized = trimmed.startsWith("nk ")
21
+ ? trimmed.slice(3).trim()
22
+ : trimmed;
23
+
24
+ // Split into tokens: op, then the rest
25
+ const spaceIdx = normalized.indexOf(" ");
26
+ if (spaceIdx === -1) {
27
+ return {
28
+ ok: false,
29
+ error: {
30
+ code: "PARSE_ERROR",
31
+ message: `Missing path: "${input}"`,
32
+ },
33
+ };
34
+ }
35
+
36
+ const op = normalized.slice(0, spaceIdx) as FsOp;
37
+ if (!VALID_OPS.has(op)) {
38
+ return {
39
+ ok: false,
40
+ error: {
41
+ code: "PARSE_ERROR",
42
+ message: `Unknown operation: "${op}". Valid: ls, cat, write, rm, grep`,
43
+ },
44
+ };
45
+ }
46
+
47
+ const rest = normalized.slice(spaceIdx + 1).trim();
48
+
49
+ if (op === "grep") {
50
+ return parseGrep(rest, input);
51
+ }
52
+
53
+ if (op === "write") {
54
+ return parseWrite(rest, input);
55
+ }
56
+
57
+ // ls, cat, rm — just need a path
58
+ const path = normalizePath(rest);
59
+ if (!path) {
60
+ return {
61
+ ok: false,
62
+ error: { code: "PARSE_ERROR", message: `Invalid path: "${rest}"` },
63
+ };
64
+ }
65
+
66
+ return { ok: true, data: { op, path } satisfies FsCommand };
67
+ }
68
+
69
+ function parseGrep(rest: string, raw: string): FsResult {
70
+ // grep "pattern" /path OR grep pattern /path
71
+ let pattern: string;
72
+ let pathPart: string;
73
+
74
+ if (rest.startsWith('"') || rest.startsWith("'")) {
75
+ const quote = rest[0];
76
+ const endQuote = rest.indexOf(quote, 1);
77
+ if (endQuote === -1) {
78
+ return {
79
+ ok: false,
80
+ error: { code: "PARSE_ERROR", message: `Unterminated quote in: "${raw}"` },
81
+ };
82
+ }
83
+ pattern = rest.slice(1, endQuote);
84
+ pathPart = rest.slice(endQuote + 1).trim();
85
+ } else {
86
+ const spaceIdx = rest.indexOf(" ");
87
+ if (spaceIdx === -1) {
88
+ return {
89
+ ok: false,
90
+ error: { code: "PARSE_ERROR", message: `grep requires pattern and path: "${raw}"` },
91
+ };
92
+ }
93
+ pattern = rest.slice(0, spaceIdx);
94
+ pathPart = rest.slice(spaceIdx + 1).trim();
95
+ }
96
+
97
+ const path = normalizePath(pathPart);
98
+ if (!path) {
99
+ return {
100
+ ok: false,
101
+ error: { code: "PARSE_ERROR", message: `Invalid path: "${pathPart}"` },
102
+ };
103
+ }
104
+
105
+ return { ok: true, data: { op: "grep", path, pattern } satisfies FsCommand };
106
+ }
107
+
108
+ function parseWrite(rest: string, raw: string): FsResult {
109
+ // write /path '{"key":"value"}' OR write /path {"key":"value"}
110
+ // Find where path ends and data begins
111
+ const pathMatch = rest.match(/^(\/\S+)\s+(.+)$/s);
112
+ if (!pathMatch) {
113
+ return {
114
+ ok: false,
115
+ error: { code: "PARSE_ERROR", message: `write requires path and data: "${raw}"` },
116
+ };
117
+ }
118
+
119
+ const path = normalizePath(pathMatch[1]);
120
+ if (!path) {
121
+ return {
122
+ ok: false,
123
+ error: { code: "PARSE_ERROR", message: `Invalid path: "${pathMatch[1]}"` },
124
+ };
125
+ }
126
+
127
+ let dataStr = pathMatch[2].trim();
128
+
129
+ // Strip surrounding quotes if present
130
+ if (
131
+ (dataStr.startsWith("'") && dataStr.endsWith("'")) ||
132
+ (dataStr.startsWith('"') && dataStr.endsWith('"'))
133
+ ) {
134
+ dataStr = dataStr.slice(1, -1);
135
+ }
136
+
137
+ let data: unknown;
138
+ try {
139
+ data = JSON.parse(dataStr);
140
+ } catch {
141
+ return {
142
+ ok: false,
143
+ error: { code: "PARSE_ERROR", message: `Invalid JSON data: ${dataStr}` },
144
+ };
145
+ }
146
+
147
+ return { ok: true, data: { op: "write", path, data } satisfies FsCommand };
148
+ }
149
+
150
+ /** Normalize and validate a path. Returns null if invalid. */
151
+ function normalizePath(raw: string): string | null {
152
+ if (!raw.startsWith("/")) return null;
153
+
154
+ // Reject path traversal
155
+ if (raw.includes("..")) return null;
156
+
157
+ // Remove trailing .json for internal routing (keep it for the user-facing path)
158
+ // Collapse double slashes
159
+ const cleaned = raw.replace(/\/+/g, "/");
160
+
161
+ return cleaned;
162
+ }
package/src/server.ts ADDED
@@ -0,0 +1,158 @@
1
+ import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
2
+ import type { AgentFs } from "./agent-fs.js";
3
+ import type { FsOp } from "./types.js";
4
+
5
+ export interface ServerOptions {
6
+ agentFs: AgentFs;
7
+ port?: number;
8
+ }
9
+
10
+ /**
11
+ * HTTP server that exposes the AgentFs as a REST API.
12
+ *
13
+ * Route mapping:
14
+ * GET /fs/* → ls (if path ends with /) or cat
15
+ * GET /fs/*?q=xxx → grep
16
+ * POST /fs/* → write
17
+ * PUT /fs/* → write
18
+ * DELETE /fs/* → rm
19
+ *
20
+ * Also accepts raw command strings via:
21
+ * POST /execute body: { command: "ls /db/users/" }
22
+ */
23
+ export function createAgentFsServer(options: ServerOptions) {
24
+ const { agentFs, port = 3071 } = options;
25
+
26
+ const server = createServer(async (req, res) => {
27
+ try {
28
+ // CORS
29
+ res.setHeader("Access-Control-Allow-Origin", "*");
30
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
31
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
32
+
33
+ if (req.method === "OPTIONS") {
34
+ res.writeHead(204);
35
+ res.end();
36
+ return;
37
+ }
38
+
39
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
40
+
41
+ // POST /execute — raw command execution
42
+ if (url.pathname === "/execute" && req.method === "POST") {
43
+ const body = await readBody(req);
44
+ const { command, roles } = body as { command: string; roles?: string[] };
45
+
46
+ if (!command || typeof command !== "string") {
47
+ sendJson(res, 400, { error: "Missing 'command' field" });
48
+ return;
49
+ }
50
+
51
+ const result = await agentFs.execute(command, roles);
52
+ const status = result.ok ? 200 : errorToStatus(result.error.code);
53
+ sendJson(res, status, result);
54
+ return;
55
+ }
56
+
57
+ // /fs/* — REST-style access
58
+ if (url.pathname.startsWith("/fs")) {
59
+ const virtualPath = url.pathname.slice(3) || "/"; // Remove "/fs" prefix
60
+ const query = url.searchParams.get("q");
61
+
62
+ let op: FsOp;
63
+ let data: unknown | undefined;
64
+ let pattern: string | undefined;
65
+
66
+ switch (req.method) {
67
+ case "GET":
68
+ if (query) {
69
+ op = "grep";
70
+ pattern = query;
71
+ } else if (virtualPath.endsWith("/")) {
72
+ op = "ls";
73
+ } else {
74
+ op = "cat";
75
+ }
76
+ break;
77
+ case "POST":
78
+ case "PUT":
79
+ op = "write";
80
+ data = await readBody(req);
81
+ break;
82
+ case "DELETE":
83
+ op = "rm";
84
+ break;
85
+ default:
86
+ sendJson(res, 405, { error: "Method not allowed" });
87
+ return;
88
+ }
89
+
90
+ const result = await agentFs.executeCommand(
91
+ { op, path: virtualPath, data, pattern },
92
+ );
93
+ const status = result.ok ? 200 : errorToStatus(result.error.code);
94
+ sendJson(res, status, result);
95
+ return;
96
+ }
97
+
98
+ sendJson(res, 404, { error: "Not found. Use /fs/* or /execute" });
99
+ } catch (err) {
100
+ sendJson(res, 500, {
101
+ error: err instanceof Error ? err.message : "Internal server error",
102
+ });
103
+ }
104
+ });
105
+
106
+ return {
107
+ server,
108
+ listen: () =>
109
+ new Promise<void>((resolve) => {
110
+ server.listen(port, () => resolve());
111
+ }),
112
+ close: () =>
113
+ new Promise<void>((resolve, reject) => {
114
+ server.close((err) => (err ? reject(err) : resolve()));
115
+ }),
116
+ port,
117
+ };
118
+ }
119
+
120
+ async function readBody(req: IncomingMessage): Promise<unknown> {
121
+ return new Promise((resolve, reject) => {
122
+ const chunks: Buffer[] = [];
123
+ req.on("data", (chunk: Buffer) => chunks.push(chunk));
124
+ req.on("end", () => {
125
+ const raw = Buffer.concat(chunks).toString("utf-8");
126
+ if (!raw) {
127
+ resolve({});
128
+ return;
129
+ }
130
+ try {
131
+ resolve(JSON.parse(raw));
132
+ } catch {
133
+ reject(new Error("Invalid JSON body"));
134
+ }
135
+ });
136
+ req.on("error", reject);
137
+ });
138
+ }
139
+
140
+ function sendJson(res: ServerResponse, status: number, data: unknown): void {
141
+ res.writeHead(status, { "Content-Type": "application/json" });
142
+ res.end(JSON.stringify(data));
143
+ }
144
+
145
+ function errorToStatus(code: string): number {
146
+ switch (code) {
147
+ case "PARSE_ERROR":
148
+ case "INVALID_PATH":
149
+ return 400;
150
+ case "PERMISSION_DENIED":
151
+ return 403;
152
+ case "NOT_FOUND":
153
+ case "NO_MOUNT":
154
+ return 404;
155
+ default:
156
+ return 500;
157
+ }
158
+ }
package/src/testing.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { MemoryBackend } from "./backends/memory.js";
2
+ export { HttpBackend } from "./backends/http.js";
3
+ export { RpcBackend } from "./backends/rpc.js";
package/src/types.ts ADDED
@@ -0,0 +1,52 @@
1
+ /** The 5 filesystem operations */
2
+ export type FsOp = "ls" | "cat" | "write" | "rm" | "grep";
3
+
4
+ /** A parsed command from the LLM */
5
+ export interface FsCommand {
6
+ op: FsOp;
7
+ path: string;
8
+ data?: unknown;
9
+ pattern?: string;
10
+ }
11
+
12
+ /** Result of a filesystem operation */
13
+ export type FsResult =
14
+ | { ok: true; data: unknown }
15
+ | { ok: false; error: FsError };
16
+
17
+ /** Error types */
18
+ export type FsError =
19
+ | { code: "PARSE_ERROR"; message: string }
20
+ | { code: "INVALID_PATH"; message: string }
21
+ | { code: "NOT_FOUND"; message: string }
22
+ | { code: "PERMISSION_DENIED"; message: string }
23
+ | { code: "NO_MOUNT"; message: string }
24
+ | { code: "BACKEND_ERROR"; message: string };
25
+
26
+ /** Agent identity context passed through the call chain */
27
+ export interface AgentContext {
28
+ id: string;
29
+ roles: string[];
30
+ }
31
+
32
+ /** Access role for permissions */
33
+ export type AccessRole = "public" | "agent" | "premium" | "admin" | string;
34
+
35
+ /** Mount point configuration — maps a virtual path to a backend */
36
+ export interface Mount {
37
+ path: string;
38
+ backend: FsBackend;
39
+ permissions?: {
40
+ read?: AccessRole[];
41
+ write?: AccessRole[];
42
+ };
43
+ }
44
+
45
+ /** The interface every backend must implement */
46
+ export interface FsBackend {
47
+ list(path: string): Promise<string[]>;
48
+ read(path: string): Promise<unknown>;
49
+ write(path: string, data: unknown): Promise<{ id: string }>;
50
+ remove(path: string): Promise<void>;
51
+ search(path: string, pattern: string): Promise<unknown[]>;
52
+ }