macha-ai 0.1.0 → 0.1.1

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/dist/ai/tools.js CHANGED
@@ -147,20 +147,68 @@ export const TOOL_DEFINITIONS = [
147
147
  },
148
148
  },
149
149
  ];
150
- function safePath(workingDir, filePath) {
150
+ function safePathLexical(workingDir, filePath) {
151
151
  const base = path.resolve(workingDir);
152
152
  const resolved = path.resolve(base, filePath);
153
153
  const rel = path.relative(base, resolved);
154
154
  if (rel.startsWith("..") || path.isAbsolute(rel)) {
155
- throw new Error(`Path traversal denied: ${filePath} escapes working directory`);
155
+ throw new Error(`Path traversal denied: "${filePath}" escapes working directory`);
156
156
  }
157
157
  return resolved;
158
158
  }
159
+ async function safeExistingPath(workingDir, filePath) {
160
+ const lexical = safePathLexical(workingDir, filePath);
161
+ const realBase = await fs.realpath(path.resolve(workingDir));
162
+ let real;
163
+ try {
164
+ real = await fs.realpath(lexical);
165
+ }
166
+ catch (e) {
167
+ const err = e;
168
+ if (err.code === "ENOENT") {
169
+ throw new Error(`File not found: "${filePath}"`);
170
+ }
171
+ throw e;
172
+ }
173
+ const rel = path.relative(realBase, real);
174
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
175
+ throw new Error(`Symlink escape denied: "${filePath}" resolves outside working directory`);
176
+ }
177
+ return real;
178
+ }
179
+ async function safeNewPath(workingDir, filePath) {
180
+ const lexical = safePathLexical(workingDir, filePath);
181
+ const realBase = await fs.realpath(path.resolve(workingDir));
182
+ let ancestor = path.dirname(lexical);
183
+ while (true) {
184
+ try {
185
+ const realAncestor = await fs.realpath(ancestor);
186
+ const rel = path.relative(realBase, realAncestor);
187
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
188
+ throw new Error(`Symlink escape denied: parent of "${filePath}" resolves outside working directory`);
189
+ }
190
+ break;
191
+ }
192
+ catch (e) {
193
+ const err = e;
194
+ if (err.code === "ENOENT") {
195
+ const parent = path.dirname(ancestor);
196
+ if (parent === ancestor)
197
+ break;
198
+ ancestor = parent;
199
+ }
200
+ else {
201
+ throw e;
202
+ }
203
+ }
204
+ }
205
+ return lexical;
206
+ }
159
207
  export async function executeTool(name, args, workingDir) {
160
208
  try {
161
209
  switch (name) {
162
210
  case "read_file": {
163
- const filePath = safePath(workingDir, args.path);
211
+ const filePath = await safeExistingPath(workingDir, args.path);
164
212
  const content = await fs.readFile(filePath, "utf-8");
165
213
  const lines = content.split("\n");
166
214
  const preview = lines.length > 500
@@ -170,7 +218,7 @@ export async function executeTool(name, args, workingDir) {
170
218
  return { success: true, output: preview };
171
219
  }
172
220
  case "write_file": {
173
- const filePath = safePath(workingDir, args.path);
221
+ const filePath = await safeNewPath(workingDir, args.path);
174
222
  await fs.mkdir(path.dirname(filePath), { recursive: true });
175
223
  await fs.writeFile(filePath, args.content, "utf-8");
176
224
  return {
@@ -180,7 +228,7 @@ export async function executeTool(name, args, workingDir) {
180
228
  }
181
229
  case "list_files": {
182
230
  const dirPath = args.path
183
- ? safePath(workingDir, args.path)
231
+ ? await safeExistingPath(workingDir, args.path)
184
232
  : workingDir;
185
233
  const maxDepth = Math.min(args.depth || 2, 5);
186
234
  const patterns = Array.from({ length: maxDepth }, (_, i) => `${"*/".repeat(i + 1)}*`);
@@ -237,7 +285,11 @@ export async function executeTool(name, args, workingDir) {
237
285
  const globPat = args.glob || "**/*";
238
286
  const caseSensitive = args.case_sensitive || false;
239
287
  if (globPat.includes("..")) {
240
- return { success: false, output: "", error: "Glob pattern must not contain '..'" };
288
+ return {
289
+ success: false,
290
+ output: "",
291
+ error: "Glob pattern must not contain '..'",
292
+ };
241
293
  }
242
294
  const files = await fg(globPat, {
243
295
  cwd: workingDir,
@@ -252,10 +304,16 @@ export async function executeTool(name, args, workingDir) {
252
304
  });
253
305
  const results = [];
254
306
  const regex = new RegExp(query, caseSensitive ? "g" : "gi");
307
+ const realBase = await fs.realpath(path.resolve(workingDir));
255
308
  for (const file of files.slice(0, 200)) {
256
309
  let resolvedFilePath;
257
310
  try {
258
- resolvedFilePath = safePath(workingDir, file);
311
+ const lexical = safePathLexical(workingDir, file);
312
+ const real = await fs.realpath(lexical);
313
+ const rel = path.relative(realBase, real);
314
+ if (rel.startsWith("..") || path.isAbsolute(rel))
315
+ continue;
316
+ resolvedFilePath = real;
259
317
  }
260
318
  catch {
261
319
  continue;
@@ -287,13 +345,13 @@ export async function executeTool(name, args, workingDir) {
287
345
  };
288
346
  }
289
347
  case "delete_file": {
290
- const filePath = safePath(workingDir, args.path);
348
+ const filePath = await safeExistingPath(workingDir, args.path);
291
349
  await fs.unlink(filePath);
292
350
  return { success: true, output: `Deleted ${args.path}` };
293
351
  }
294
352
  case "move_file": {
295
- const from = safePath(workingDir, args.from);
296
- const to = safePath(workingDir, args.to);
353
+ const from = await safeExistingPath(workingDir, args.from);
354
+ const to = await safeNewPath(workingDir, args.to);
297
355
  await fs.mkdir(path.dirname(to), { recursive: true });
298
356
  await fs.rename(from, to);
299
357
  return { success: true, output: `Moved ${args.from} → ${args.to}` };
@@ -1,4 +1,43 @@
1
1
  import { execa } from "execa";
2
+ function writeMessage(state, msg) {
3
+ const body = JSON.stringify(msg);
4
+ const header = `Content-Length: ${Buffer.byteLength(body, "utf-8")}\r\n\r\n`;
5
+ state.process.stdin?.write(header + body);
6
+ }
7
+ function parseFrames(state) {
8
+ const messages = [];
9
+ while (true) {
10
+ const buf = state.buffer;
11
+ const sep = findCRLFCRLF(buf);
12
+ if (sep === -1)
13
+ break;
14
+ const headerStr = buf.slice(0, sep).toString("utf-8");
15
+ const contentLengthMatch = headerStr.match(/Content-Length:\s*(\d+)/i);
16
+ if (!contentLengthMatch) {
17
+ state.buffer = buf.slice(sep + 4);
18
+ continue;
19
+ }
20
+ const bodyLen = parseInt(contentLengthMatch[1], 10);
21
+ const bodyStart = sep + 4;
22
+ const bodyEnd = bodyStart + bodyLen;
23
+ if (buf.length < bodyEnd)
24
+ break;
25
+ messages.push(buf.slice(bodyStart, bodyEnd).toString("utf-8"));
26
+ state.buffer = buf.slice(bodyEnd);
27
+ }
28
+ return messages;
29
+ }
30
+ function findCRLFCRLF(buf) {
31
+ for (let i = 0; i <= buf.length - 4; i++) {
32
+ if (buf[i] === 0x0d &&
33
+ buf[i + 1] === 0x0a &&
34
+ buf[i + 2] === 0x0d &&
35
+ buf[i + 3] === 0x0a) {
36
+ return i;
37
+ }
38
+ }
39
+ return -1;
40
+ }
2
41
  export class MCPClient {
3
42
  servers = new Map();
4
43
  msgId = 1;
@@ -9,39 +48,62 @@ export class MCPClient {
9
48
  stdio: ["pipe", "pipe", "pipe"],
10
49
  all: false,
11
50
  });
12
- const state = { process: proc, buffer: "", pending: new Map() };
51
+ const state = {
52
+ process: proc,
53
+ buffer: Buffer.alloc(0),
54
+ pending: new Map(),
55
+ };
13
56
  this.servers.set(server.id, state);
14
57
  proc.stdout?.on("data", (chunk) => {
15
- state.buffer += chunk.toString();
16
- const lines = state.buffer.split("\n");
17
- state.buffer = lines.pop() || "";
18
- for (const line of lines) {
19
- if (!line.trim())
20
- continue;
58
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
59
+ state.buffer = Buffer.concat([state.buffer, bytes]);
60
+ for (const raw of parseFrames(state)) {
61
+ let msg;
21
62
  try {
22
- const msg = JSON.parse(line);
23
- if (msg.id !== undefined) {
24
- const pending = state.pending.get(msg.id);
25
- if (pending) {
26
- state.pending.delete(msg.id);
27
- if (msg.error) {
28
- pending.reject(new Error(msg.error.message));
29
- }
30
- else {
31
- pending.resolve(msg.result);
32
- }
33
- }
34
- }
63
+ msg = JSON.parse(raw);
35
64
  }
36
65
  catch {
66
+ continue;
37
67
  }
68
+ if (msg.id !== undefined) {
69
+ const pending = state.pending.get(msg.id);
70
+ if (pending) {
71
+ state.pending.delete(msg.id);
72
+ if (msg.error) {
73
+ pending.reject(new Error(msg.error.message));
74
+ }
75
+ else {
76
+ pending.resolve(msg.result);
77
+ }
78
+ }
79
+ }
80
+ }
81
+ });
82
+ const rejectAll = (reason) => {
83
+ for (const [id, pending] of state.pending) {
84
+ state.pending.delete(id);
85
+ pending.reject(new Error(reason));
38
86
  }
87
+ };
88
+ proc.on("exit", (code) => {
89
+ rejectAll(`MCP server exited with code ${code ?? "unknown"}`);
90
+ this.servers.delete(server.id);
91
+ });
92
+ proc.on("error", (err) => {
93
+ rejectAll(`MCP server process error: ${err.message}`);
94
+ this.servers.delete(server.id);
39
95
  });
40
96
  await this.send(server.id, "initialize", {
41
97
  protocolVersion: "2024-11-05",
42
98
  capabilities: { tools: {}, resources: {} },
43
99
  clientInfo: { name: "macha", version: "0.1.0" },
44
100
  });
101
+ const initNotification = {
102
+ jsonrpc: "2.0",
103
+ method: "notifications/initialized",
104
+ params: {},
105
+ };
106
+ writeMessage(state, initNotification);
45
107
  }
46
108
  async send(serverId, method, params) {
47
109
  const state = this.servers.get(serverId);
@@ -50,14 +112,23 @@ export class MCPClient {
50
112
  const id = this.msgId++;
51
113
  const msg = { jsonrpc: "2.0", id, method, params };
52
114
  return new Promise((resolve, reject) => {
53
- state.pending.set(id, { resolve, reject });
54
- state.process.stdin?.write(JSON.stringify(msg) + "\n");
55
- setTimeout(() => {
115
+ const timer = setTimeout(() => {
56
116
  if (state.pending.has(id)) {
57
117
  state.pending.delete(id);
58
- reject(new Error("MCP request timed out"));
118
+ reject(new Error(`MCP request timed out (method: ${method})`));
59
119
  }
60
120
  }, 10000);
121
+ state.pending.set(id, {
122
+ resolve: (v) => {
123
+ clearTimeout(timer);
124
+ resolve(v);
125
+ },
126
+ reject: (e) => {
127
+ clearTimeout(timer);
128
+ reject(e);
129
+ },
130
+ });
131
+ writeMessage(state, msg);
61
132
  });
62
133
  }
63
134
  async listTools(serverId, serverName) {
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "macha-ai",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "A beautiful terminal AI coding assistant — like Claude Code and OpenCode. Supports any OpenAI-compatible provider, MCP servers, file tools, and command execution.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "macha": "./bin/macha.js"
7
+ "macha": "bin/macha.js"
8
8
  },
9
9
  "files": [
10
10
  "bin/",