@poncho-ai/harness 0.35.0 → 0.36.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/.turbo/turbo-build.log +12 -11
- package/CHANGELOG.md +25 -0
- package/dist/index.d.ts +485 -29
- package/dist/index.js +2839 -2114
- package/dist/isolate-TCWTUVG4.js +1532 -0
- package/package.json +23 -4
- package/scripts/migrate-to-engine.mjs +556 -0
- package/src/config.ts +106 -1
- package/src/harness.ts +226 -91
- package/src/index.ts +5 -0
- package/src/isolate/bindings.ts +206 -0
- package/src/isolate/bundler.ts +179 -0
- package/src/isolate/index.ts +10 -0
- package/src/isolate/polyfills.ts +796 -0
- package/src/isolate/run-code-tool.ts +220 -0
- package/src/isolate/runtime.ts +286 -0
- package/src/isolate/type-stubs.ts +196 -0
- package/src/memory.ts +129 -198
- package/src/reminder-store.ts +3 -237
- package/src/secrets-store.ts +2 -91
- package/src/state.ts +11 -1302
- package/src/storage/engine.ts +106 -0
- package/src/storage/index.ts +59 -0
- package/src/storage/memory-engine.ts +588 -0
- package/src/storage/postgres-engine.ts +139 -0
- package/src/storage/schema.ts +145 -0
- package/src/storage/sql-dialect.ts +963 -0
- package/src/storage/sqlite-engine.ts +99 -0
- package/src/storage/store-adapters.ts +100 -0
- package/src/todo-tools.ts +1 -136
- package/src/upload-store.ts +1 -0
- package/src/vfs/bash-manager.ts +120 -0
- package/src/vfs/bash-tool.ts +59 -0
- package/src/vfs/create-bash-fs.ts +32 -0
- package/src/vfs/edit-file-tool.ts +72 -0
- package/src/vfs/index.ts +5 -0
- package/src/vfs/poncho-fs-adapter.ts +267 -0
- package/src/vfs/protected-fs.ts +177 -0
- package/src/vfs/read-file-tool.ts +103 -0
- package/src/vfs/write-file-tool.ts +49 -0
- package/test/harness.test.ts +30 -36
- package/test/isolate-vfs.test.ts +453 -0
- package/test/isolate.test.ts +252 -0
- package/test/state.test.ts +4 -27
- package/test/storage-engine.test.ts +250 -0
- package/test/vfs.test.ts +242 -0
- package/.turbo/turbo-lint.log +0 -6
- package/.turbo/turbo-test.log +0 -11931
- package/src/kv-store.ts +0 -216
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// PonchoFsAdapter – implements just-bash's IFileSystem backed by StorageEngine.
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
BufferEncoding,
|
|
7
|
+
CpOptions,
|
|
8
|
+
FileContent,
|
|
9
|
+
FsStat,
|
|
10
|
+
IFileSystem,
|
|
11
|
+
MkdirOptions,
|
|
12
|
+
RmOptions,
|
|
13
|
+
} from "just-bash";
|
|
14
|
+
import type { StorageEngine } from "../storage/engine.js";
|
|
15
|
+
|
|
16
|
+
const MIME_MAP: Record<string, string> = {
|
|
17
|
+
".txt": "text/plain",
|
|
18
|
+
".html": "text/html",
|
|
19
|
+
".htm": "text/html",
|
|
20
|
+
".css": "text/css",
|
|
21
|
+
".js": "application/javascript",
|
|
22
|
+
".mjs": "application/javascript",
|
|
23
|
+
".json": "application/json",
|
|
24
|
+
".xml": "application/xml",
|
|
25
|
+
".csv": "text/csv",
|
|
26
|
+
".md": "text/markdown",
|
|
27
|
+
".ts": "text/typescript",
|
|
28
|
+
".tsx": "text/typescript",
|
|
29
|
+
".jsx": "text/javascript",
|
|
30
|
+
".yaml": "text/yaml",
|
|
31
|
+
".yml": "text/yaml",
|
|
32
|
+
".svg": "image/svg+xml",
|
|
33
|
+
".png": "image/png",
|
|
34
|
+
".jpg": "image/jpeg",
|
|
35
|
+
".jpeg": "image/jpeg",
|
|
36
|
+
".gif": "image/gif",
|
|
37
|
+
".webp": "image/webp",
|
|
38
|
+
".pdf": "application/pdf",
|
|
39
|
+
".zip": "application/zip",
|
|
40
|
+
".gz": "application/gzip",
|
|
41
|
+
".tar": "application/x-tar",
|
|
42
|
+
".sh": "application/x-sh",
|
|
43
|
+
".py": "text/x-python",
|
|
44
|
+
".rb": "text/x-ruby",
|
|
45
|
+
".go": "text/x-go",
|
|
46
|
+
".rs": "text/x-rust",
|
|
47
|
+
".java": "text/x-java",
|
|
48
|
+
".c": "text/x-c",
|
|
49
|
+
".cpp": "text/x-c++",
|
|
50
|
+
".h": "text/x-c",
|
|
51
|
+
".sql": "application/sql",
|
|
52
|
+
".wasm": "application/wasm",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const mimeFromExtension = (path: string): string | undefined => {
|
|
56
|
+
const dot = path.lastIndexOf(".");
|
|
57
|
+
if (dot === -1) return undefined;
|
|
58
|
+
return MIME_MAP[path.slice(dot).toLowerCase()];
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const normalize = (path: string): string => {
|
|
62
|
+
// Resolve . and .. segments, collapse multiple slashes
|
|
63
|
+
const parts = path.split("/");
|
|
64
|
+
const out: string[] = [];
|
|
65
|
+
for (const p of parts) {
|
|
66
|
+
if (p === "" || p === ".") continue;
|
|
67
|
+
if (p === "..") {
|
|
68
|
+
out.pop();
|
|
69
|
+
} else {
|
|
70
|
+
out.push(p);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return "/" + out.join("/");
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export class PonchoFsAdapter implements IFileSystem {
|
|
77
|
+
constructor(
|
|
78
|
+
private engine: StorageEngine,
|
|
79
|
+
private tenantId: string,
|
|
80
|
+
private limits: { maxFileSize: number; maxTotalStorage: number },
|
|
81
|
+
) {}
|
|
82
|
+
|
|
83
|
+
// --- Reads ---
|
|
84
|
+
|
|
85
|
+
async readFile(path: string, _options?: { encoding?: BufferEncoding | null } | BufferEncoding): Promise<string> {
|
|
86
|
+
const buf = await this.engine.vfs.readFile(this.tenantId, normalize(path));
|
|
87
|
+
return new TextDecoder().decode(buf);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async readFileBuffer(path: string): Promise<Uint8Array> {
|
|
91
|
+
return this.engine.vfs.readFile(this.tenantId, normalize(path));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async exists(path: string): Promise<boolean> {
|
|
95
|
+
const s = await this.engine.vfs.stat(this.tenantId, normalize(path));
|
|
96
|
+
return s !== undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async stat(path: string): Promise<FsStat> {
|
|
100
|
+
const np = normalize(path);
|
|
101
|
+
const s = await this.engine.vfs.stat(this.tenantId, np);
|
|
102
|
+
if (!s) throw new Error(`ENOENT: no such file or directory, stat '${path}'`);
|
|
103
|
+
return {
|
|
104
|
+
isFile: s.type === "file",
|
|
105
|
+
isDirectory: s.type === "directory",
|
|
106
|
+
isSymbolicLink: s.type === "symlink",
|
|
107
|
+
mode: s.mode,
|
|
108
|
+
size: s.size,
|
|
109
|
+
mtime: new Date(s.updatedAt),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async readdir(path: string): Promise<string[]> {
|
|
114
|
+
const entries = await this.engine.vfs.readdir(this.tenantId, normalize(path));
|
|
115
|
+
return entries.map((e) => e.name);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async readdirWithFileTypes(path: string): Promise<Array<{ name: string; isFile: boolean; isDirectory: boolean; isSymbolicLink: boolean }>> {
|
|
119
|
+
const entries = await this.engine.vfs.readdir(this.tenantId, normalize(path));
|
|
120
|
+
return entries.map((e) => ({
|
|
121
|
+
name: e.name,
|
|
122
|
+
isFile: e.type === "file",
|
|
123
|
+
isDirectory: e.type === "directory",
|
|
124
|
+
isSymbolicLink: e.type === "symlink",
|
|
125
|
+
}));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// --- Writes ---
|
|
129
|
+
|
|
130
|
+
async writeFile(
|
|
131
|
+
path: string,
|
|
132
|
+
content: FileContent,
|
|
133
|
+
_options?: { encoding?: BufferEncoding } | BufferEncoding,
|
|
134
|
+
): Promise<void> {
|
|
135
|
+
const buf = typeof content === "string" ? new TextEncoder().encode(content) : content;
|
|
136
|
+
if (buf.byteLength > this.limits.maxFileSize) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`File too large: ${buf.byteLength} bytes exceeds limit of ${this.limits.maxFileSize} bytes`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
const mime = mimeFromExtension(path);
|
|
142
|
+
await this.engine.vfs.writeFile(this.tenantId, normalize(path), buf, mime);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async appendFile(
|
|
146
|
+
path: string,
|
|
147
|
+
content: FileContent,
|
|
148
|
+
_options?: { encoding?: BufferEncoding } | BufferEncoding,
|
|
149
|
+
): Promise<void> {
|
|
150
|
+
const buf = typeof content === "string" ? new TextEncoder().encode(content) : content;
|
|
151
|
+
await this.engine.vfs.appendFile(this.tenantId, normalize(path), buf);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async mkdir(path: string, options?: MkdirOptions): Promise<void> {
|
|
155
|
+
await this.engine.vfs.mkdir(this.tenantId, normalize(path), options?.recursive);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async rm(path: string, options?: RmOptions): Promise<void> {
|
|
159
|
+
const np = normalize(path);
|
|
160
|
+
const s = await this.engine.vfs.stat(this.tenantId, np);
|
|
161
|
+
if (!s) {
|
|
162
|
+
if (options?.force) return;
|
|
163
|
+
throw new Error(`ENOENT: no such file or directory, rm '${path}'`);
|
|
164
|
+
}
|
|
165
|
+
if (s.type === "directory") {
|
|
166
|
+
await this.engine.vfs.deleteDir(this.tenantId, np, options?.recursive);
|
|
167
|
+
} else {
|
|
168
|
+
await this.engine.vfs.deleteFile(this.tenantId, np);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// --- Compound ops ---
|
|
173
|
+
|
|
174
|
+
async cp(src: string, dest: string, options?: CpOptions): Promise<void> {
|
|
175
|
+
const srcNorm = normalize(src);
|
|
176
|
+
const destNorm = normalize(dest);
|
|
177
|
+
const srcStat = await this.engine.vfs.stat(this.tenantId, srcNorm);
|
|
178
|
+
if (!srcStat) throw new Error(`ENOENT: no such file or directory, cp '${src}'`);
|
|
179
|
+
|
|
180
|
+
if (srcStat.type === "directory") {
|
|
181
|
+
if (!options?.recursive) {
|
|
182
|
+
throw new Error(`EISDIR: cp -r not specified; omitting directory '${src}'`);
|
|
183
|
+
}
|
|
184
|
+
await this.engine.vfs.mkdir(this.tenantId, destNorm, true);
|
|
185
|
+
const entries = await this.engine.vfs.readdir(this.tenantId, srcNorm);
|
|
186
|
+
for (const entry of entries) {
|
|
187
|
+
await this.cp(`${srcNorm}/${entry.name}`, `${destNorm}/${entry.name}`, options);
|
|
188
|
+
}
|
|
189
|
+
} else {
|
|
190
|
+
const content = await this.engine.vfs.readFile(this.tenantId, srcNorm);
|
|
191
|
+
const mime = mimeFromExtension(destNorm);
|
|
192
|
+
await this.engine.vfs.writeFile(this.tenantId, destNorm, content, mime);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async mv(src: string, dest: string): Promise<void> {
|
|
197
|
+
await this.engine.vfs.rename(this.tenantId, normalize(src), normalize(dest));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// --- Path resolution ---
|
|
201
|
+
|
|
202
|
+
resolvePath(base: string, path: string): string {
|
|
203
|
+
if (path.startsWith("/")) return normalize(path);
|
|
204
|
+
return normalize(`${base}/${path}`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async realpath(path: string): Promise<string> {
|
|
208
|
+
const np = normalize(path);
|
|
209
|
+
// Resolve symlinks in the path
|
|
210
|
+
const s = await this.engine.vfs.lstat(this.tenantId, np);
|
|
211
|
+
if (!s) throw new Error(`ENOENT: no such file or directory, realpath '${path}'`);
|
|
212
|
+
if (s.type === "symlink" && s.symlinkTarget) {
|
|
213
|
+
const target = s.symlinkTarget.startsWith("/")
|
|
214
|
+
? s.symlinkTarget
|
|
215
|
+
: normalize(`${np.slice(0, np.lastIndexOf("/"))}/${s.symlinkTarget}`);
|
|
216
|
+
return this.realpath(target);
|
|
217
|
+
}
|
|
218
|
+
return np;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// --- Sync: required by just-bash for glob/find ---
|
|
222
|
+
|
|
223
|
+
getAllPaths(): string[] {
|
|
224
|
+
return this.engine.vfs.listAllPaths(this.tenantId);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// --- Metadata ---
|
|
228
|
+
|
|
229
|
+
async chmod(path: string, mode: number): Promise<void> {
|
|
230
|
+
await this.engine.vfs.chmod(this.tenantId, normalize(path), mode);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async utimes(path: string, _atime: Date, mtime: Date): Promise<void> {
|
|
234
|
+
await this.engine.vfs.utimes(this.tenantId, normalize(path), mtime);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// --- Symlinks ---
|
|
238
|
+
|
|
239
|
+
async symlink(target: string, linkPath: string): Promise<void> {
|
|
240
|
+
await this.engine.vfs.symlink(this.tenantId, target, normalize(linkPath));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async link(existingPath: string, newPath: string): Promise<void> {
|
|
244
|
+
// Hard link: copy content
|
|
245
|
+
const content = await this.engine.vfs.readFile(this.tenantId, normalize(existingPath));
|
|
246
|
+
const mime = mimeFromExtension(newPath);
|
|
247
|
+
await this.engine.vfs.writeFile(this.tenantId, normalize(newPath), content, mime);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async readlink(path: string): Promise<string> {
|
|
251
|
+
return this.engine.vfs.readlink(this.tenantId, normalize(path));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async lstat(path: string): Promise<FsStat> {
|
|
255
|
+
const np = normalize(path);
|
|
256
|
+
const s = await this.engine.vfs.lstat(this.tenantId, np);
|
|
257
|
+
if (!s) throw new Error(`ENOENT: no such file or directory, lstat '${path}'`);
|
|
258
|
+
return {
|
|
259
|
+
isFile: s.type === "file",
|
|
260
|
+
isDirectory: s.type === "directory",
|
|
261
|
+
isSymbolicLink: s.type === "symlink",
|
|
262
|
+
mode: s.mode,
|
|
263
|
+
size: s.size,
|
|
264
|
+
mtime: new Date(s.updatedAt),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// ProtectedFs – write protection wrapper for project files.
|
|
3
|
+
// Blocks writes/deletes to sensitive paths. All reads pass through.
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
BufferEncoding,
|
|
8
|
+
CpOptions,
|
|
9
|
+
FileContent,
|
|
10
|
+
FsStat,
|
|
11
|
+
IFileSystem,
|
|
12
|
+
MkdirOptions,
|
|
13
|
+
RmOptions,
|
|
14
|
+
} from "just-bash";
|
|
15
|
+
|
|
16
|
+
const DEFAULT_PROTECTED_PATTERNS = [
|
|
17
|
+
".env",
|
|
18
|
+
".env.*",
|
|
19
|
+
".git/",
|
|
20
|
+
"node_modules/",
|
|
21
|
+
".poncho/",
|
|
22
|
+
"poncho.config.*",
|
|
23
|
+
"pnpm-lock.yaml",
|
|
24
|
+
"package-lock.json",
|
|
25
|
+
"yarn.lock",
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
function matchProtectedPattern(pattern: string, path: string): boolean {
|
|
29
|
+
// Normalize: strip leading /
|
|
30
|
+
const rel = path.startsWith("/") ? path.slice(1) : path;
|
|
31
|
+
const segments = rel.split("/");
|
|
32
|
+
|
|
33
|
+
if (pattern.endsWith("/")) {
|
|
34
|
+
// Directory prefix: block this dir and everything under it
|
|
35
|
+
const dir = pattern.slice(0, -1);
|
|
36
|
+
return segments[0] === dir || rel.startsWith(dir + "/");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (pattern.includes("*")) {
|
|
40
|
+
// Wildcard: convert to regex
|
|
41
|
+
const re = new RegExp(
|
|
42
|
+
"^" + pattern.replace(/\./g, "\\.").replace(/\*/g, "[^/]*") + "$",
|
|
43
|
+
);
|
|
44
|
+
return re.test(segments[0] ?? "") || re.test(rel);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Exact match on the first segment or full path
|
|
48
|
+
return segments[0] === pattern || rel === pattern;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class ProtectedFs implements IFileSystem {
|
|
52
|
+
private inner: IFileSystem;
|
|
53
|
+
private patterns: string[];
|
|
54
|
+
|
|
55
|
+
constructor(inner: IFileSystem, patterns?: string[]) {
|
|
56
|
+
this.inner = inner;
|
|
57
|
+
this.patterns = patterns ?? DEFAULT_PROTECTED_PATTERNS;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private isProtected(path: string): boolean {
|
|
61
|
+
return this.patterns.some((p) => matchProtectedPattern(p, path));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private guard(path: string, op: string): void {
|
|
65
|
+
if (this.isProtected(path)) {
|
|
66
|
+
throw new Error(`Permission denied: ${path} is protected (${op})`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// --- Reads: pass through ---
|
|
71
|
+
|
|
72
|
+
readFile(path: string, options?: { encoding?: BufferEncoding | null } | BufferEncoding): Promise<string> {
|
|
73
|
+
return this.inner.readFile(path, options);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
readFileBuffer(path: string): Promise<Uint8Array> {
|
|
77
|
+
return this.inner.readFileBuffer(path);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
exists(path: string): Promise<boolean> {
|
|
81
|
+
return this.inner.exists(path);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
stat(path: string): Promise<FsStat> {
|
|
85
|
+
return this.inner.stat(path);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
readdir(path: string): Promise<string[]> {
|
|
89
|
+
return this.inner.readdir(path);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
readdirWithFileTypes(path: string): Promise<Array<{ name: string; isFile: boolean; isDirectory: boolean; isSymbolicLink: boolean }>> {
|
|
93
|
+
if (this.inner.readdirWithFileTypes) {
|
|
94
|
+
return this.inner.readdirWithFileTypes(path);
|
|
95
|
+
}
|
|
96
|
+
throw new Error("readdirWithFileTypes not supported");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
lstat(path: string): Promise<FsStat> {
|
|
100
|
+
return this.inner.lstat(path);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
readlink(path: string): Promise<string> {
|
|
104
|
+
return this.inner.readlink(path);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
realpath(path: string): Promise<string> {
|
|
108
|
+
return this.inner.realpath(path);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
getAllPaths(): string[] {
|
|
112
|
+
return this.inner.getAllPaths();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
resolvePath(base: string, path: string): string {
|
|
116
|
+
return this.inner.resolvePath(base, path);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// --- Writes: check protection ---
|
|
120
|
+
|
|
121
|
+
writeFile(
|
|
122
|
+
path: string,
|
|
123
|
+
content: FileContent,
|
|
124
|
+
options?: { encoding?: BufferEncoding } | BufferEncoding,
|
|
125
|
+
): Promise<void> {
|
|
126
|
+
this.guard(path, "write");
|
|
127
|
+
return this.inner.writeFile(path, content, options);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
appendFile(
|
|
131
|
+
path: string,
|
|
132
|
+
content: FileContent,
|
|
133
|
+
options?: { encoding?: BufferEncoding } | BufferEncoding,
|
|
134
|
+
): Promise<void> {
|
|
135
|
+
this.guard(path, "append");
|
|
136
|
+
return this.inner.appendFile(path, content, options);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
mkdir(path: string, options?: MkdirOptions): Promise<void> {
|
|
140
|
+
return this.inner.mkdir(path, options);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
rm(path: string, options?: RmOptions): Promise<void> {
|
|
144
|
+
this.guard(path, "rm");
|
|
145
|
+
return this.inner.rm(path, options);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
cp(src: string, dest: string, options?: CpOptions): Promise<void> {
|
|
149
|
+
this.guard(dest, "cp");
|
|
150
|
+
return this.inner.cp(src, dest, options);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
mv(src: string, dest: string): Promise<void> {
|
|
154
|
+
this.guard(src, "mv");
|
|
155
|
+
this.guard(dest, "mv");
|
|
156
|
+
return this.inner.mv(src, dest);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
chmod(path: string, mode: number): Promise<void> {
|
|
160
|
+
this.guard(path, "chmod");
|
|
161
|
+
return this.inner.chmod(path, mode);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
utimes(path: string, atime: Date, mtime: Date): Promise<void> {
|
|
165
|
+
return this.inner.utimes(path, atime, mtime);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
symlink(target: string, linkPath: string): Promise<void> {
|
|
169
|
+
this.guard(linkPath, "symlink");
|
|
170
|
+
return this.inner.symlink(target, linkPath);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
link(existingPath: string, newPath: string): Promise<void> {
|
|
174
|
+
this.guard(newPath, "link");
|
|
175
|
+
return this.inner.link(existingPath, newPath);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// read_file tool – read files from the VFS, returning binary files (images,
|
|
3
|
+
// PDFs) as FileContentPart references that the harness resolves lazily at
|
|
4
|
+
// model-request time via the vfs:// scheme.
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
import { defineTool, type ToolDefinition } from "@poncho-ai/sdk";
|
|
8
|
+
import type { StorageEngine } from "../storage/engine.js";
|
|
9
|
+
import { VFS_SCHEME } from "../upload-store.js";
|
|
10
|
+
|
|
11
|
+
const MIME_MAP: Record<string, string> = {
|
|
12
|
+
".txt": "text/plain",
|
|
13
|
+
".html": "text/html",
|
|
14
|
+
".htm": "text/html",
|
|
15
|
+
".css": "text/css",
|
|
16
|
+
".js": "application/javascript",
|
|
17
|
+
".json": "application/json",
|
|
18
|
+
".xml": "application/xml",
|
|
19
|
+
".csv": "text/csv",
|
|
20
|
+
".md": "text/markdown",
|
|
21
|
+
".ts": "text/typescript",
|
|
22
|
+
".yaml": "text/yaml",
|
|
23
|
+
".yml": "text/yaml",
|
|
24
|
+
".svg": "image/svg+xml",
|
|
25
|
+
".png": "image/png",
|
|
26
|
+
".jpg": "image/jpeg",
|
|
27
|
+
".jpeg": "image/jpeg",
|
|
28
|
+
".gif": "image/gif",
|
|
29
|
+
".webp": "image/webp",
|
|
30
|
+
".pdf": "application/pdf",
|
|
31
|
+
".py": "text/x-python",
|
|
32
|
+
".sh": "application/x-sh",
|
|
33
|
+
".sql": "application/sql",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const mimeFromPath = (path: string): string | undefined => {
|
|
37
|
+
const dot = path.lastIndexOf(".");
|
|
38
|
+
if (dot === -1) return undefined;
|
|
39
|
+
return MIME_MAP[path.slice(dot).toLowerCase()];
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const isTextMime = (mime: string): boolean =>
|
|
43
|
+
mime.startsWith("text/") ||
|
|
44
|
+
mime === "application/json" ||
|
|
45
|
+
mime === "application/xml" ||
|
|
46
|
+
mime === "application/sql" ||
|
|
47
|
+
mime === "application/javascript" ||
|
|
48
|
+
mime === "application/x-sh";
|
|
49
|
+
|
|
50
|
+
export const createReadFileTool = (
|
|
51
|
+
engine: StorageEngine,
|
|
52
|
+
): ToolDefinition => defineTool({
|
|
53
|
+
name: "read_file",
|
|
54
|
+
description:
|
|
55
|
+
"Read a file from the virtual filesystem. " +
|
|
56
|
+
"Returns text content for text-based files, or sends images and PDFs " +
|
|
57
|
+
"directly to the model for visual analysis.",
|
|
58
|
+
inputSchema: {
|
|
59
|
+
type: "object",
|
|
60
|
+
properties: {
|
|
61
|
+
path: {
|
|
62
|
+
type: "string",
|
|
63
|
+
description: "Absolute path of the file to read (e.g. /data/report.pdf, /screenshots/page.png)",
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
required: ["path"],
|
|
67
|
+
additionalProperties: false,
|
|
68
|
+
},
|
|
69
|
+
handler: async (input, context) => {
|
|
70
|
+
const filePath = typeof input.path === "string" ? input.path.trim() : "";
|
|
71
|
+
if (!filePath) {
|
|
72
|
+
throw new Error("path is required");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const tenantId = context.tenantId ?? "__default__";
|
|
76
|
+
const stat = await engine.vfs.stat(tenantId, filePath);
|
|
77
|
+
if (!stat) {
|
|
78
|
+
throw new Error(`File not found: ${filePath}`);
|
|
79
|
+
}
|
|
80
|
+
if (stat.type === "directory") {
|
|
81
|
+
throw new Error(`${filePath} is a directory, not a file`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const mediaType = stat.mimeType ?? mimeFromPath(filePath) ?? "application/octet-stream";
|
|
85
|
+
const filename = filePath.split("/").pop() ?? filePath;
|
|
86
|
+
|
|
87
|
+
// Text files: read and return inline
|
|
88
|
+
if (isTextMime(mediaType)) {
|
|
89
|
+
const buf = await engine.vfs.readFile(tenantId, filePath);
|
|
90
|
+
const text = Buffer.from(buf).toString("utf8");
|
|
91
|
+
return { filename, mediaType, content: text };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Images and PDFs: return a vfs:// reference that the harness resolves
|
|
95
|
+
// lazily at model-request time — the actual bytes never sit in context.
|
|
96
|
+
return {
|
|
97
|
+
type: "file",
|
|
98
|
+
data: `${VFS_SCHEME}${filePath}`,
|
|
99
|
+
mediaType,
|
|
100
|
+
filename,
|
|
101
|
+
};
|
|
102
|
+
},
|
|
103
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// write_file tool – create or overwrite a file in the VFS.
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
import { defineTool, type ToolDefinition } from "@poncho-ai/sdk";
|
|
6
|
+
import type { StorageEngine } from "../storage/engine.js";
|
|
7
|
+
|
|
8
|
+
export const createWriteFileTool = (
|
|
9
|
+
engine: StorageEngine,
|
|
10
|
+
): ToolDefinition => defineTool({
|
|
11
|
+
name: "write_file",
|
|
12
|
+
description:
|
|
13
|
+
"Create a new file or overwrite an existing file in the virtual filesystem. " +
|
|
14
|
+
"Parent directories are created automatically. " +
|
|
15
|
+
"Prefer edit_file for targeted changes to existing files.",
|
|
16
|
+
inputSchema: {
|
|
17
|
+
type: "object",
|
|
18
|
+
properties: {
|
|
19
|
+
path: {
|
|
20
|
+
type: "string",
|
|
21
|
+
description: "Absolute path of the file to write (e.g. /data/output.json)",
|
|
22
|
+
},
|
|
23
|
+
content: {
|
|
24
|
+
type: "string",
|
|
25
|
+
description: "The full content to write to the file",
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
required: ["path", "content"],
|
|
29
|
+
additionalProperties: false,
|
|
30
|
+
},
|
|
31
|
+
handler: async (input, context) => {
|
|
32
|
+
const filePath = typeof input.path === "string" ? input.path.trim() : "";
|
|
33
|
+
const content = typeof input.content === "string" ? input.content : "";
|
|
34
|
+
|
|
35
|
+
if (!filePath) throw new Error("path is required");
|
|
36
|
+
|
|
37
|
+
const tenantId = context.tenantId ?? "__default__";
|
|
38
|
+
|
|
39
|
+
// Create parent directories
|
|
40
|
+
const dir = filePath.slice(0, filePath.lastIndexOf("/"));
|
|
41
|
+
if (dir) {
|
|
42
|
+
await engine.vfs.mkdir(tenantId, dir, true);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
await engine.vfs.writeFile(tenantId, filePath, new TextEncoder().encode(content));
|
|
46
|
+
|
|
47
|
+
return { ok: true, path: filePath };
|
|
48
|
+
},
|
|
49
|
+
});
|