btca-server 1.0.63 → 1.0.71
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/README.md +4 -1
- package/package.json +4 -2
- package/src/agent/agent.test.ts +114 -16
- package/src/agent/loop.ts +14 -11
- package/src/agent/service.ts +117 -86
- package/src/collections/index.ts +0 -0
- package/src/collections/service.ts +187 -57
- package/src/collections/types.ts +1 -0
- package/src/collections/virtual-metadata.ts +32 -0
- package/src/config/config.test.ts +0 -0
- package/src/config/index.ts +195 -127
- package/src/config/remote.ts +132 -79
- package/src/context/index.ts +0 -0
- package/src/context/transaction.ts +20 -15
- package/src/errors.ts +0 -0
- package/src/index.ts +29 -15
- package/src/metrics/index.ts +18 -13
- package/src/providers/auth.ts +38 -11
- package/src/providers/model.ts +3 -1
- package/src/providers/openrouter.ts +39 -0
- package/src/providers/registry.ts +2 -0
- package/src/resources/helpers.ts +0 -0
- package/src/resources/impls/git.test.ts +0 -0
- package/src/resources/impls/git.ts +160 -117
- package/src/resources/index.ts +0 -0
- package/src/resources/schema.ts +24 -27
- package/src/resources/service.ts +0 -0
- package/src/resources/types.ts +0 -0
- package/src/stream/index.ts +0 -0
- package/src/stream/service.ts +23 -14
- package/src/tools/context.ts +4 -0
- package/src/tools/glob.ts +72 -45
- package/src/tools/grep.ts +136 -57
- package/src/tools/index.ts +0 -2
- package/src/tools/list.ts +34 -53
- package/src/tools/read.ts +46 -32
- package/src/tools/virtual-sandbox.ts +103 -0
- package/src/validation/index.ts +12 -12
- package/src/vfs/virtual-fs.test.ts +107 -0
- package/src/vfs/virtual-fs.ts +273 -0
- package/src/tools/ripgrep.ts +0 -348
- package/src/tools/sandbox.ts +0 -164
package/src/tools/grep.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Grep Tool
|
|
3
|
-
* Searches file contents using regular expressions
|
|
3
|
+
* Searches file contents using regular expressions in-memory
|
|
4
4
|
*/
|
|
5
|
-
import * as fs from 'node:fs/promises';
|
|
6
5
|
import * as path from 'node:path';
|
|
7
6
|
import { z } from 'zod';
|
|
7
|
+
import { Result } from 'better-result';
|
|
8
8
|
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
9
|
+
import type { ToolContext } from './context.ts';
|
|
10
|
+
import { VirtualSandbox } from './virtual-sandbox.ts';
|
|
11
|
+
import { VirtualFs } from '../vfs/virtual-fs.ts';
|
|
11
12
|
|
|
12
13
|
export namespace GrepTool {
|
|
13
14
|
// Configuration
|
|
@@ -39,33 +40,40 @@ export namespace GrepTool {
|
|
|
39
40
|
};
|
|
40
41
|
};
|
|
41
42
|
|
|
43
|
+
const safeStat = async (filePath: string, vfsId?: string) => {
|
|
44
|
+
const result = await Result.tryPromise(() => VirtualFs.stat(filePath, vfsId));
|
|
45
|
+
return result.match({
|
|
46
|
+
ok: (value) => value,
|
|
47
|
+
err: () => null
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const safeReadBuffer = async (filePath: string, vfsId?: string) => {
|
|
52
|
+
const result = await Result.tryPromise(() => VirtualFs.readFileBuffer(filePath, vfsId));
|
|
53
|
+
return result.match({
|
|
54
|
+
ok: (value) => value,
|
|
55
|
+
err: () => null
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const compileRegex = (pattern: string) =>
|
|
60
|
+
Result.try(() => new RegExp(pattern)).match({
|
|
61
|
+
ok: (value) => value,
|
|
62
|
+
err: () => null
|
|
63
|
+
});
|
|
64
|
+
|
|
42
65
|
/**
|
|
43
66
|
* Execute the grep tool
|
|
44
67
|
*/
|
|
45
|
-
export async function execute(
|
|
46
|
-
|
|
47
|
-
context: { basePath: string }
|
|
48
|
-
): Promise<Result> {
|
|
49
|
-
const { basePath } = context;
|
|
68
|
+
export async function execute(params: ParametersType, context: ToolContext): Promise<Result> {
|
|
69
|
+
const { basePath, vfsId } = context;
|
|
50
70
|
|
|
51
71
|
// Resolve search path within sandbox
|
|
52
|
-
const searchPath = params.path ?
|
|
72
|
+
const searchPath = params.path ? VirtualSandbox.resolvePath(basePath, params.path) : basePath;
|
|
53
73
|
|
|
54
74
|
// Validate the search path exists and is a directory
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (!stats.isDirectory()) {
|
|
58
|
-
return {
|
|
59
|
-
title: params.pattern,
|
|
60
|
-
output: `Path is not a directory: ${params.path || '.'}`,
|
|
61
|
-
metadata: {
|
|
62
|
-
matchCount: 0,
|
|
63
|
-
fileCount: 0,
|
|
64
|
-
truncated: false
|
|
65
|
-
}
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
} catch {
|
|
75
|
+
const stats = await safeStat(searchPath, vfsId);
|
|
76
|
+
if (!stats) {
|
|
69
77
|
return {
|
|
70
78
|
title: params.pattern,
|
|
71
79
|
output: `Directory not found: ${params.path || '.'}`,
|
|
@@ -76,15 +84,59 @@ export namespace GrepTool {
|
|
|
76
84
|
}
|
|
77
85
|
};
|
|
78
86
|
}
|
|
87
|
+
if (!stats.isDirectory) {
|
|
88
|
+
return {
|
|
89
|
+
title: params.pattern,
|
|
90
|
+
output: `Path is not a directory: ${params.path || '.'}`,
|
|
91
|
+
metadata: {
|
|
92
|
+
matchCount: 0,
|
|
93
|
+
fileCount: 0,
|
|
94
|
+
truncated: false
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
}
|
|
79
98
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
99
|
+
const regex = compileRegex(params.pattern);
|
|
100
|
+
if (!regex) {
|
|
101
|
+
return {
|
|
102
|
+
title: params.pattern,
|
|
103
|
+
output: 'Invalid regex pattern.',
|
|
104
|
+
metadata: {
|
|
105
|
+
matchCount: 0,
|
|
106
|
+
fileCount: 0,
|
|
107
|
+
truncated: false
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const includeMatcher = params.include ? buildIncludeMatcher(params.include) : null;
|
|
113
|
+
const allFiles = await VirtualFs.listFilesRecursive(searchPath, vfsId);
|
|
114
|
+
const results: Array<{ path: string; lineNumber: number; lineText: string; mtime: number }> =
|
|
115
|
+
[];
|
|
116
|
+
|
|
117
|
+
for (const filePath of allFiles) {
|
|
118
|
+
if (results.length > MAX_RESULTS) break;
|
|
119
|
+
const relative = path.posix.relative(searchPath, filePath);
|
|
120
|
+
if (includeMatcher && !includeMatcher(relative)) continue;
|
|
121
|
+
const buffer = await safeReadBuffer(filePath, vfsId);
|
|
122
|
+
if (!buffer) continue;
|
|
123
|
+
if (isBinaryBuffer(buffer)) continue;
|
|
124
|
+
const text = await VirtualFs.readFile(filePath, vfsId);
|
|
125
|
+
const lines = text.split('\n');
|
|
126
|
+
const fileStats = await safeStat(filePath, vfsId);
|
|
127
|
+
const mtime = fileStats?.mtimeMs ?? 0;
|
|
128
|
+
for (let i = 0; i < lines.length; i++) {
|
|
129
|
+
const lineText = lines[i] ?? '';
|
|
130
|
+
if (!regex.test(lineText)) continue;
|
|
131
|
+
results.push({
|
|
132
|
+
path: filePath,
|
|
133
|
+
lineNumber: i + 1,
|
|
134
|
+
lineText,
|
|
135
|
+
mtime
|
|
136
|
+
});
|
|
137
|
+
if (results.length > MAX_RESULTS) break;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
88
140
|
|
|
89
141
|
if (results.length === 0) {
|
|
90
142
|
return {
|
|
@@ -98,30 +150,13 @@ export namespace GrepTool {
|
|
|
98
150
|
};
|
|
99
151
|
}
|
|
100
152
|
|
|
101
|
-
// Check for truncation
|
|
102
153
|
const truncated = results.length > MAX_RESULTS;
|
|
103
154
|
const displayResults = truncated ? results.slice(0, MAX_RESULTS) : results;
|
|
155
|
+
displayResults.sort((a, b) => b.mtime - a.mtime);
|
|
104
156
|
|
|
105
|
-
// Sort by modification time (most recent first)
|
|
106
|
-
// Get file modification times
|
|
107
|
-
const filesWithMtime = await Promise.all(
|
|
108
|
-
displayResults.map(async (result) => {
|
|
109
|
-
try {
|
|
110
|
-
const stats = await fs.stat(result.path);
|
|
111
|
-
return { ...result, mtime: stats.mtime.getTime() };
|
|
112
|
-
} catch {
|
|
113
|
-
return { ...result, mtime: 0 };
|
|
114
|
-
}
|
|
115
|
-
})
|
|
116
|
-
);
|
|
117
|
-
|
|
118
|
-
filesWithMtime.sort((a, b) => b.mtime - a.mtime);
|
|
119
|
-
|
|
120
|
-
// Group results by file
|
|
121
157
|
const fileGroups = new Map<string, Array<{ lineNumber: number; lineText: string }>>();
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const relativePath = path.relative(basePath, result.path);
|
|
158
|
+
for (const result of displayResults) {
|
|
159
|
+
const relativePath = path.posix.relative(basePath, result.path);
|
|
125
160
|
if (!fileGroups.has(relativePath)) {
|
|
126
161
|
fileGroups.set(relativePath, []);
|
|
127
162
|
}
|
|
@@ -131,21 +166,17 @@ export namespace GrepTool {
|
|
|
131
166
|
});
|
|
132
167
|
}
|
|
133
168
|
|
|
134
|
-
// Format output
|
|
135
169
|
const outputLines: string[] = [];
|
|
136
|
-
|
|
137
170
|
for (const [filePath, matches] of fileGroups) {
|
|
138
171
|
outputLines.push(`${filePath}:`);
|
|
139
172
|
for (const match of matches) {
|
|
140
|
-
// Truncate long lines
|
|
141
173
|
const lineText =
|
|
142
174
|
match.lineText.length > 200 ? match.lineText.substring(0, 200) + '...' : match.lineText;
|
|
143
175
|
outputLines.push(` ${match.lineNumber}: ${lineText}`);
|
|
144
176
|
}
|
|
145
|
-
outputLines.push('');
|
|
177
|
+
outputLines.push('');
|
|
146
178
|
}
|
|
147
179
|
|
|
148
|
-
// Add truncation notice
|
|
149
180
|
if (truncated) {
|
|
150
181
|
outputLines.push(
|
|
151
182
|
`[Truncated: Results limited to ${MAX_RESULTS} matches. Narrow your search pattern for more specific results.]`
|
|
@@ -162,4 +193,52 @@ export namespace GrepTool {
|
|
|
162
193
|
}
|
|
163
194
|
};
|
|
164
195
|
}
|
|
196
|
+
|
|
197
|
+
function isBinaryBuffer(bytes: Uint8Array): boolean {
|
|
198
|
+
for (const byte of bytes) {
|
|
199
|
+
if (byte === 0) return true;
|
|
200
|
+
}
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function globToRegExp(pattern: string): RegExp {
|
|
205
|
+
let regex = '^';
|
|
206
|
+
let i = 0;
|
|
207
|
+
while (i < pattern.length) {
|
|
208
|
+
const char = pattern[i] ?? '';
|
|
209
|
+
const next = pattern[i + 1] ?? '';
|
|
210
|
+
if (char === '*' && next === '*') {
|
|
211
|
+
regex += '.*';
|
|
212
|
+
i += 2;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (char === '*') {
|
|
216
|
+
regex += '[^/]*';
|
|
217
|
+
i += 1;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (char === '?') {
|
|
221
|
+
regex += '[^/]';
|
|
222
|
+
i += 1;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if ('\\.^$+{}()|[]'.includes(char)) {
|
|
226
|
+
regex += '\\' + char;
|
|
227
|
+
} else {
|
|
228
|
+
regex += char;
|
|
229
|
+
}
|
|
230
|
+
i += 1;
|
|
231
|
+
}
|
|
232
|
+
regex += '$';
|
|
233
|
+
return new RegExp(regex);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function buildIncludeMatcher(pattern: string): (relativePath: string) => boolean {
|
|
237
|
+
const regex = globToRegExp(pattern);
|
|
238
|
+
if (!pattern.includes('/')) {
|
|
239
|
+
return (relativePath) =>
|
|
240
|
+
regex.test(path.posix.basename(relativePath)) || regex.test(relativePath);
|
|
241
|
+
}
|
|
242
|
+
return (relativePath) => regex.test(relativePath);
|
|
243
|
+
}
|
|
165
244
|
}
|
package/src/tools/index.ts
CHANGED
package/src/tools/list.ts
CHANGED
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
* List Tool
|
|
3
3
|
* Lists directory contents with file types
|
|
4
4
|
*/
|
|
5
|
-
import * as fs from 'node:fs/promises';
|
|
6
5
|
import * as path from 'node:path';
|
|
7
6
|
import { z } from 'zod';
|
|
7
|
+
import { Result } from 'better-result';
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import type { ToolContext } from './context.ts';
|
|
10
|
+
import { VirtualSandbox } from './virtual-sandbox.ts';
|
|
11
|
+
import { VirtualFs } from '../vfs/virtual-fs.ts';
|
|
10
12
|
|
|
11
13
|
export namespace ListTool {
|
|
12
14
|
// Schema for tool parameters
|
|
@@ -19,7 +21,7 @@ export namespace ListTool {
|
|
|
19
21
|
// Entry type
|
|
20
22
|
export type Entry = {
|
|
21
23
|
name: string;
|
|
22
|
-
type: 'file' | 'directory' | '
|
|
24
|
+
type: 'file' | 'directory' | 'other';
|
|
23
25
|
size?: number;
|
|
24
26
|
};
|
|
25
27
|
|
|
@@ -34,33 +36,26 @@ export namespace ListTool {
|
|
|
34
36
|
};
|
|
35
37
|
};
|
|
36
38
|
|
|
39
|
+
const safeStat = async (filePath: string, vfsId?: string) => {
|
|
40
|
+
const result = await Result.tryPromise(() => VirtualFs.stat(filePath, vfsId));
|
|
41
|
+
return result.match({
|
|
42
|
+
ok: (value) => value,
|
|
43
|
+
err: () => null
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
|
|
37
47
|
/**
|
|
38
48
|
* Execute the list tool
|
|
39
49
|
*/
|
|
40
|
-
export async function execute(
|
|
41
|
-
|
|
42
|
-
context: { basePath: string }
|
|
43
|
-
): Promise<Result> {
|
|
44
|
-
const { basePath } = context;
|
|
50
|
+
export async function execute(params: ParametersType, context: ToolContext): Promise<Result> {
|
|
51
|
+
const { basePath, vfsId } = context;
|
|
45
52
|
|
|
46
53
|
// Resolve path within sandbox
|
|
47
|
-
const resolvedPath =
|
|
54
|
+
const resolvedPath = VirtualSandbox.resolvePath(basePath, params.path);
|
|
48
55
|
|
|
49
56
|
// Check if path exists
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if (!stats.isDirectory()) {
|
|
53
|
-
return {
|
|
54
|
-
title: params.path,
|
|
55
|
-
output: `Path is not a directory: ${params.path}`,
|
|
56
|
-
metadata: {
|
|
57
|
-
entries: [],
|
|
58
|
-
fileCount: 0,
|
|
59
|
-
directoryCount: 0
|
|
60
|
-
}
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
} catch {
|
|
57
|
+
const stats = await safeStat(resolvedPath, vfsId);
|
|
58
|
+
if (!stats) {
|
|
64
59
|
return {
|
|
65
60
|
title: params.path,
|
|
66
61
|
output: `Directory not found: ${params.path}`,
|
|
@@ -71,44 +66,32 @@ export namespace ListTool {
|
|
|
71
66
|
}
|
|
72
67
|
};
|
|
73
68
|
}
|
|
69
|
+
if (!stats.isDirectory) {
|
|
70
|
+
return {
|
|
71
|
+
title: params.path,
|
|
72
|
+
output: `Path is not a directory: ${params.path}`,
|
|
73
|
+
metadata: {
|
|
74
|
+
entries: [],
|
|
75
|
+
fileCount: 0,
|
|
76
|
+
directoryCount: 0
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
}
|
|
74
80
|
|
|
75
81
|
// Read directory contents
|
|
76
|
-
const dirents = await fs.readdir(resolvedPath, { withFileTypes: true });
|
|
77
|
-
|
|
78
|
-
// Process entries
|
|
79
82
|
const entries: Entry[] = [];
|
|
80
83
|
|
|
84
|
+
const dirents = await VirtualFs.readdir(resolvedPath, vfsId);
|
|
81
85
|
for (const dirent of dirents) {
|
|
82
86
|
let type: Entry['type'] = 'other';
|
|
83
87
|
let size: number | undefined;
|
|
84
|
-
|
|
85
|
-
if (dirent.isDirectory()) {
|
|
88
|
+
if (dirent.isDirectory) {
|
|
86
89
|
type = 'directory';
|
|
87
|
-
} else if (dirent.isFile
|
|
90
|
+
} else if (dirent.isFile) {
|
|
88
91
|
type = 'file';
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
size = stats.size;
|
|
92
|
-
} catch {
|
|
93
|
-
// Ignore stat errors
|
|
94
|
-
}
|
|
95
|
-
} else if (dirent.isSymbolicLink()) {
|
|
96
|
-
type = 'symlink';
|
|
97
|
-
// Try to determine if symlink points to file or directory
|
|
98
|
-
try {
|
|
99
|
-
const stats = await fs.stat(path.join(resolvedPath, dirent.name));
|
|
100
|
-
if (stats.isDirectory()) {
|
|
101
|
-
type = 'directory';
|
|
102
|
-
} else if (stats.isFile()) {
|
|
103
|
-
type = 'file';
|
|
104
|
-
size = stats.size;
|
|
105
|
-
}
|
|
106
|
-
} catch {
|
|
107
|
-
// Keep as symlink if we can't resolve
|
|
108
|
-
type = 'symlink';
|
|
109
|
-
}
|
|
92
|
+
const stats = await safeStat(path.posix.join(resolvedPath, dirent.name), vfsId);
|
|
93
|
+
size = stats?.size;
|
|
110
94
|
}
|
|
111
|
-
|
|
112
95
|
entries.push({
|
|
113
96
|
name: dirent.name,
|
|
114
97
|
type,
|
|
@@ -135,8 +118,6 @@ export namespace ListTool {
|
|
|
135
118
|
|
|
136
119
|
if (entry.type === 'directory') {
|
|
137
120
|
line = `[DIR] ${entry.name}/`;
|
|
138
|
-
} else if (entry.type === 'symlink') {
|
|
139
|
-
line = `[LNK] ${entry.name}`;
|
|
140
121
|
} else if (entry.type === 'file') {
|
|
141
122
|
const sizeStr = entry.size !== undefined ? formatSize(entry.size) : '';
|
|
142
123
|
line = `[FILE] ${entry.name}${sizeStr ? ` (${sizeStr})` : ''}`;
|
package/src/tools/read.ts
CHANGED
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
* Read Tool
|
|
3
3
|
* Reads file contents with line numbers, truncation, and special file handling
|
|
4
4
|
*/
|
|
5
|
-
import * as fs from 'node:fs/promises';
|
|
6
5
|
import * as path from 'node:path';
|
|
7
6
|
import { z } from 'zod';
|
|
7
|
+
import { Result } from 'better-result';
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import type { ToolContext } from './context.ts';
|
|
10
|
+
import { VirtualSandbox } from './virtual-sandbox.ts';
|
|
11
|
+
import { VirtualFs } from '../vfs/virtual-fs.ts';
|
|
10
12
|
|
|
11
13
|
export namespace ReadTool {
|
|
12
14
|
// Configuration
|
|
@@ -65,48 +67,38 @@ export namespace ReadTool {
|
|
|
65
67
|
/**
|
|
66
68
|
* Check if a file is binary by looking for null bytes
|
|
67
69
|
*/
|
|
68
|
-
|
|
69
|
-
const file = Bun.file(filepath);
|
|
70
|
-
const chunk = await file.slice(0, 8192).arrayBuffer();
|
|
71
|
-
const bytes = new Uint8Array(chunk);
|
|
72
|
-
|
|
70
|
+
function isBinaryBuffer(bytes: Uint8Array): boolean {
|
|
73
71
|
for (const byte of bytes) {
|
|
74
|
-
if (byte === 0)
|
|
75
|
-
return true;
|
|
76
|
-
}
|
|
72
|
+
if (byte === 0) return true;
|
|
77
73
|
}
|
|
78
|
-
|
|
79
74
|
return false;
|
|
80
75
|
}
|
|
81
76
|
|
|
82
77
|
/**
|
|
83
78
|
* Execute the read tool
|
|
84
79
|
*/
|
|
85
|
-
export async function execute(
|
|
86
|
-
|
|
87
|
-
context: { basePath: string }
|
|
88
|
-
): Promise<Result> {
|
|
89
|
-
const { basePath } = context;
|
|
80
|
+
export async function execute(params: ParametersType, context: ToolContext): Promise<Result> {
|
|
81
|
+
const { basePath, vfsId } = context;
|
|
90
82
|
|
|
91
83
|
// Validate and resolve path within sandbox
|
|
92
|
-
const resolvedPath = await
|
|
84
|
+
const resolvedPath = await VirtualSandbox.resolvePathWithSymlinks(basePath, params.path, vfsId);
|
|
93
85
|
|
|
94
86
|
// Check if file exists
|
|
95
|
-
const
|
|
96
|
-
if (!
|
|
87
|
+
const exists = await VirtualFs.exists(resolvedPath, vfsId);
|
|
88
|
+
if (!exists) {
|
|
97
89
|
// Try to provide suggestions
|
|
98
90
|
const dir = path.dirname(resolvedPath);
|
|
99
91
|
const filename = path.basename(resolvedPath);
|
|
100
92
|
let suggestions: string[] = [];
|
|
101
93
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
94
|
+
const filesResult = await Result.tryPromise(() => VirtualFs.readdir(dir, vfsId));
|
|
95
|
+
const files = filesResult.match({
|
|
96
|
+
ok: (entries) => entries.map((entry) => entry.name),
|
|
97
|
+
err: () => []
|
|
98
|
+
});
|
|
99
|
+
suggestions = files
|
|
100
|
+
.filter((f) => f.toLowerCase().includes(filename.toLowerCase().slice(0, 3)))
|
|
101
|
+
.slice(0, 5);
|
|
110
102
|
|
|
111
103
|
const suggestionText =
|
|
112
104
|
suggestions.length > 0
|
|
@@ -127,9 +119,9 @@ export namespace ReadTool {
|
|
|
127
119
|
|
|
128
120
|
// Handle images
|
|
129
121
|
if (IMAGE_EXTENSIONS.has(ext)) {
|
|
130
|
-
const bytes = await
|
|
122
|
+
const bytes = await VirtualFs.readFileBuffer(resolvedPath, vfsId);
|
|
131
123
|
const base64 = Buffer.from(bytes).toString('base64');
|
|
132
|
-
const mime =
|
|
124
|
+
const mime = getImageMime(ext);
|
|
133
125
|
|
|
134
126
|
return {
|
|
135
127
|
title: params.path,
|
|
@@ -151,7 +143,7 @@ export namespace ReadTool {
|
|
|
151
143
|
|
|
152
144
|
// Handle PDFs
|
|
153
145
|
if (PDF_EXTENSIONS.has(ext)) {
|
|
154
|
-
const bytes = await
|
|
146
|
+
const bytes = await VirtualFs.readFileBuffer(resolvedPath, vfsId);
|
|
155
147
|
const base64 = Buffer.from(bytes).toString('base64');
|
|
156
148
|
|
|
157
149
|
return {
|
|
@@ -173,7 +165,7 @@ export namespace ReadTool {
|
|
|
173
165
|
}
|
|
174
166
|
|
|
175
167
|
// Check for binary files
|
|
176
|
-
if (await
|
|
168
|
+
if (isBinaryBuffer(await VirtualFs.readFileBuffer(resolvedPath, vfsId))) {
|
|
177
169
|
return {
|
|
178
170
|
title: params.path,
|
|
179
171
|
output: `[Binary file: ${path.basename(resolvedPath)}]`,
|
|
@@ -186,7 +178,7 @@ export namespace ReadTool {
|
|
|
186
178
|
}
|
|
187
179
|
|
|
188
180
|
// Read text file
|
|
189
|
-
const text = await
|
|
181
|
+
const text = await VirtualFs.readFile(resolvedPath, vfsId);
|
|
190
182
|
const allLines = text.split('\n');
|
|
191
183
|
|
|
192
184
|
const offset = params.offset ?? 0;
|
|
@@ -252,4 +244,26 @@ export namespace ReadTool {
|
|
|
252
244
|
}
|
|
253
245
|
};
|
|
254
246
|
}
|
|
247
|
+
|
|
248
|
+
function getImageMime(ext: string): string {
|
|
249
|
+
switch (ext) {
|
|
250
|
+
case '.png':
|
|
251
|
+
return 'image/png';
|
|
252
|
+
case '.jpg':
|
|
253
|
+
case '.jpeg':
|
|
254
|
+
return 'image/jpeg';
|
|
255
|
+
case '.gif':
|
|
256
|
+
return 'image/gif';
|
|
257
|
+
case '.webp':
|
|
258
|
+
return 'image/webp';
|
|
259
|
+
case '.bmp':
|
|
260
|
+
return 'image/bmp';
|
|
261
|
+
case '.ico':
|
|
262
|
+
return 'image/x-icon';
|
|
263
|
+
case '.svg':
|
|
264
|
+
return 'image/svg+xml';
|
|
265
|
+
default:
|
|
266
|
+
return 'application/octet-stream';
|
|
267
|
+
}
|
|
268
|
+
}
|
|
255
269
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import * as path from 'node:path';
|
|
2
|
+
|
|
3
|
+
import { Result } from 'better-result';
|
|
4
|
+
|
|
5
|
+
import { VirtualFs } from '../vfs/virtual-fs.ts';
|
|
6
|
+
|
|
7
|
+
const posix = path.posix;
|
|
8
|
+
|
|
9
|
+
export namespace VirtualSandbox {
|
|
10
|
+
export class PathEscapeError extends Error {
|
|
11
|
+
readonly _tag = 'PathEscapeError';
|
|
12
|
+
readonly requestedPath: string;
|
|
13
|
+
readonly basePath: string;
|
|
14
|
+
|
|
15
|
+
constructor(requestedPath: string, basePath: string) {
|
|
16
|
+
super(
|
|
17
|
+
`Path "${requestedPath}" is outside the allowed directory "${basePath}". Access denied.`
|
|
18
|
+
);
|
|
19
|
+
this.requestedPath = requestedPath;
|
|
20
|
+
this.basePath = basePath;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class PathNotFoundError extends Error {
|
|
25
|
+
readonly _tag = 'PathNotFoundError';
|
|
26
|
+
readonly requestedPath: string;
|
|
27
|
+
|
|
28
|
+
constructor(requestedPath: string) {
|
|
29
|
+
super(`Path "${requestedPath}" does not exist.`);
|
|
30
|
+
this.requestedPath = requestedPath;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function resolvePath(basePath: string, requestedPath: string): string {
|
|
35
|
+
const normalizedBase = posix.resolve('/', basePath);
|
|
36
|
+
const resolved = posix.isAbsolute(requestedPath)
|
|
37
|
+
? posix.resolve(requestedPath)
|
|
38
|
+
: posix.resolve(normalizedBase, requestedPath);
|
|
39
|
+
const normalized = posix.normalize(resolved);
|
|
40
|
+
const relative = posix.relative(normalizedBase, normalized);
|
|
41
|
+
|
|
42
|
+
if (relative.startsWith('..') || posix.isAbsolute(relative)) {
|
|
43
|
+
throw new PathEscapeError(requestedPath, basePath);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return normalized;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function resolvePathWithSymlinks(
|
|
50
|
+
basePath: string,
|
|
51
|
+
requestedPath: string,
|
|
52
|
+
vfsId?: string
|
|
53
|
+
) {
|
|
54
|
+
const resolved = resolvePath(basePath, requestedPath);
|
|
55
|
+
const result = await Result.tryPromise(() => VirtualFs.realpath(resolved, vfsId));
|
|
56
|
+
return result.match({
|
|
57
|
+
ok: (value) => value,
|
|
58
|
+
err: () => resolved
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function exists(basePath: string, requestedPath: string, vfsId?: string) {
|
|
63
|
+
const resolvedResult = Result.try(() => resolvePath(basePath, requestedPath));
|
|
64
|
+
if (!Result.isOk(resolvedResult)) return false;
|
|
65
|
+
const result = await Result.tryPromise(() => VirtualFs.exists(resolvedResult.value, vfsId));
|
|
66
|
+
return result.match({
|
|
67
|
+
ok: (value) => value,
|
|
68
|
+
err: () => false
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function isDirectory(basePath: string, requestedPath: string, vfsId?: string) {
|
|
73
|
+
const resolvedResult = Result.try(() => resolvePath(basePath, requestedPath));
|
|
74
|
+
if (!Result.isOk(resolvedResult)) return false;
|
|
75
|
+
const result = await Result.tryPromise(() => VirtualFs.stat(resolvedResult.value, vfsId));
|
|
76
|
+
return result.match({
|
|
77
|
+
ok: (stats) => stats.isDirectory,
|
|
78
|
+
err: () => false
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function isFile(basePath: string, requestedPath: string, vfsId?: string) {
|
|
83
|
+
const resolvedResult = Result.try(() => resolvePath(basePath, requestedPath));
|
|
84
|
+
if (!Result.isOk(resolvedResult)) return false;
|
|
85
|
+
const result = await Result.tryPromise(() => VirtualFs.stat(resolvedResult.value, vfsId));
|
|
86
|
+
return result.match({
|
|
87
|
+
ok: (stats) => stats.isFile,
|
|
88
|
+
err: () => false
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function validatePath(basePath: string, requestedPath: string, vfsId?: string) {
|
|
93
|
+
const resolved = resolvePath(basePath, requestedPath);
|
|
94
|
+
if (!(await VirtualFs.exists(resolved, vfsId))) {
|
|
95
|
+
throw new PathNotFoundError(requestedPath);
|
|
96
|
+
}
|
|
97
|
+
return resolved;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function getRelativePath(basePath: string, resolvedPath: string): string {
|
|
101
|
+
return posix.relative(basePath, resolvedPath);
|
|
102
|
+
}
|
|
103
|
+
}
|