@poncho-ai/harness 0.43.0 → 0.44.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.
- package/.turbo/turbo-build.log +5 -5
- package/CHANGELOG.md +91 -0
- package/dist/index.d.ts +96 -45
- package/dist/index.js +367 -98
- package/package.json +1 -1
- package/src/harness.ts +32 -2
- package/src/orchestrator/run-conversation-turn.ts +2 -2
- package/src/storage/schema.ts +19 -0
- package/src/storage/sql-dialect.ts +11 -3
- package/src/upload-store.ts +27 -0
- package/src/vfs/bash-manager.ts +6 -3
- package/src/vfs/poncho-fs-adapter.ts +333 -51
- package/test/upload-store-decode.test.ts +43 -0
- package/test/vfs.test.ts +111 -0
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
// ---------------------------------------------------------------------------
|
|
2
2
|
// PonchoFsAdapter – implements just-bash's IFileSystem backed by StorageEngine.
|
|
3
|
+
//
|
|
4
|
+
// Optionally supports read-only virtual mounts: a VFS prefix (e.g. "/system/")
|
|
5
|
+
// that resolves to a local filesystem directory. Reads under the prefix are
|
|
6
|
+
// served from local disk; writes are rejected. Used by PonchOS to expose
|
|
7
|
+
// deployment-shipped defaults (system jobs, system skills) without storing
|
|
8
|
+
// them in each tenant's VFS, so improvements ship via normal deploys and
|
|
9
|
+
// users export their personal data without inheriting frozen system content.
|
|
3
10
|
// ---------------------------------------------------------------------------
|
|
4
11
|
|
|
12
|
+
import * as nodeFs from "node:fs/promises";
|
|
13
|
+
import * as nodeFsSync from "node:fs";
|
|
14
|
+
import * as nodePath from "node:path";
|
|
15
|
+
|
|
5
16
|
import type {
|
|
6
17
|
BufferEncoding,
|
|
7
18
|
CpOptions,
|
|
@@ -73,56 +84,233 @@ const normalize = (path: string): string => {
|
|
|
73
84
|
return "/" + out.join("/");
|
|
74
85
|
};
|
|
75
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Read-only virtual mount mapping a VFS path prefix to a local filesystem
|
|
89
|
+
* directory. All read operations under the prefix resolve via local FS;
|
|
90
|
+
* writes throw. The prefix is normalised internally to end with "/".
|
|
91
|
+
*/
|
|
92
|
+
export interface VirtualMount {
|
|
93
|
+
/** VFS prefix, e.g. "/system/". Leading slash required; trailing slash
|
|
94
|
+
* optional (normalised). Must be a single non-root segment in practice
|
|
95
|
+
* but no validation is enforced here. */
|
|
96
|
+
prefix: string;
|
|
97
|
+
/** Absolute local FS path to serve from, e.g. "/srv/poncho/system". */
|
|
98
|
+
source: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Internal normalised form: prefix always ends with "/", source has no
|
|
102
|
+
* trailing slash. */
|
|
103
|
+
interface NormalisedMount {
|
|
104
|
+
prefix: string;
|
|
105
|
+
prefixNoSlash: string;
|
|
106
|
+
source: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const READ_ONLY_ERROR = (path: string, op: string): Error =>
|
|
110
|
+
new Error(`EROFS: read-only mount, ${op} '${path}'`);
|
|
111
|
+
|
|
76
112
|
export class PonchoFsAdapter implements IFileSystem {
|
|
113
|
+
private mounts: NormalisedMount[];
|
|
114
|
+
|
|
77
115
|
constructor(
|
|
78
116
|
private engine: StorageEngine,
|
|
79
117
|
private tenantId: string,
|
|
80
118
|
private limits: { maxFileSize: number; maxTotalStorage: number },
|
|
81
|
-
|
|
119
|
+
mounts: VirtualMount[] = [],
|
|
120
|
+
) {
|
|
121
|
+
this.mounts = mounts.map((m) => {
|
|
122
|
+
const prefix = m.prefix.endsWith("/") ? m.prefix : m.prefix + "/";
|
|
123
|
+
return {
|
|
124
|
+
prefix,
|
|
125
|
+
prefixNoSlash: prefix.slice(0, -1),
|
|
126
|
+
source: m.source.replace(/\/+$/, ""),
|
|
127
|
+
};
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Find which mount, if any, a normalised VFS path falls under.
|
|
132
|
+
* Returns the relative path within the mount's source dir (empty string
|
|
133
|
+
* when the path is exactly the mount root). */
|
|
134
|
+
private routeToMount(np: string): { mount: NormalisedMount; relative: string } | null {
|
|
135
|
+
for (const m of this.mounts) {
|
|
136
|
+
if (np === m.prefixNoSlash) return { mount: m, relative: "" };
|
|
137
|
+
if (np.startsWith(m.prefix)) return { mount: m, relative: np.slice(m.prefix.length) };
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Treat `np` as a directory and return mount-root segments that should be
|
|
143
|
+
* listed as virtual subdirectories. E.g. with mount "/system/", reading
|
|
144
|
+
* "/" returns ["system"]; reading "/system" goes via routeToMount and
|
|
145
|
+
* serves from local FS instead. */
|
|
146
|
+
private virtualChildrenAt(np: string): string[] {
|
|
147
|
+
const dirPrefix = np === "/" ? "/" : np + "/";
|
|
148
|
+
const out: string[] = [];
|
|
149
|
+
for (const m of this.mounts) {
|
|
150
|
+
if (m.prefix.startsWith(dirPrefix) && m.prefix !== dirPrefix) {
|
|
151
|
+
const remaining = m.prefix.slice(dirPrefix.length);
|
|
152
|
+
const seg = remaining.split("/")[0];
|
|
153
|
+
if (seg && !out.includes(seg)) out.push(seg);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private toLocal(mount: NormalisedMount, relative: string): string {
|
|
160
|
+
// nodePath.join handles empty relative -> source dir
|
|
161
|
+
return nodePath.join(mount.source, relative);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Build an FsStat from a node fs.Stats. */
|
|
165
|
+
private toFsStat(s: nodeFsSync.Stats): FsStat {
|
|
166
|
+
return {
|
|
167
|
+
isFile: s.isFile(),
|
|
168
|
+
isDirectory: s.isDirectory(),
|
|
169
|
+
isSymbolicLink: s.isSymbolicLink(),
|
|
170
|
+
mode: s.mode,
|
|
171
|
+
size: s.size,
|
|
172
|
+
mtime: s.mtime,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Synthesise a directory stat for a virtual ancestor (e.g. "/system"
|
|
177
|
+
* when "/system/jobs/" is mounted but "/system" itself isn't a real dir
|
|
178
|
+
* on disk). Used so `ls /` and `stat /system` work without surprises. */
|
|
179
|
+
private syntheticDirStat(): FsStat {
|
|
180
|
+
return {
|
|
181
|
+
isFile: false,
|
|
182
|
+
isDirectory: true,
|
|
183
|
+
isSymbolicLink: false,
|
|
184
|
+
mode: 0o755,
|
|
185
|
+
size: 0,
|
|
186
|
+
mtime: new Date(0),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
82
189
|
|
|
83
190
|
// --- Reads ---
|
|
84
191
|
|
|
85
192
|
async readFile(path: string, _options?: { encoding?: BufferEncoding | null } | BufferEncoding): Promise<string> {
|
|
86
|
-
const
|
|
193
|
+
const np = normalize(path);
|
|
194
|
+
const route = this.routeToMount(np);
|
|
195
|
+
if (route) {
|
|
196
|
+
const buf = await nodeFs.readFile(this.toLocal(route.mount, route.relative));
|
|
197
|
+
return buf.toString("utf8");
|
|
198
|
+
}
|
|
199
|
+
const buf = await this.engine.vfs.readFile(this.tenantId, np);
|
|
87
200
|
return new TextDecoder().decode(buf);
|
|
88
201
|
}
|
|
89
202
|
|
|
90
203
|
async readFileBuffer(path: string): Promise<Uint8Array> {
|
|
91
|
-
|
|
204
|
+
const np = normalize(path);
|
|
205
|
+
const route = this.routeToMount(np);
|
|
206
|
+
if (route) {
|
|
207
|
+
const buf = await nodeFs.readFile(this.toLocal(route.mount, route.relative));
|
|
208
|
+
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
209
|
+
}
|
|
210
|
+
return this.engine.vfs.readFile(this.tenantId, np);
|
|
92
211
|
}
|
|
93
212
|
|
|
94
213
|
async exists(path: string): Promise<boolean> {
|
|
95
|
-
const
|
|
96
|
-
|
|
214
|
+
const np = normalize(path);
|
|
215
|
+
const route = this.routeToMount(np);
|
|
216
|
+
if (route) {
|
|
217
|
+
try {
|
|
218
|
+
await nodeFs.access(this.toLocal(route.mount, route.relative));
|
|
219
|
+
return true;
|
|
220
|
+
} catch {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// Virtual ancestor of a mount (e.g. "/" when only "/system/" is mounted):
|
|
225
|
+
// it exists if either the engine has it OR it's a real ancestor of a mount.
|
|
226
|
+
const s = await this.engine.vfs.stat(this.tenantId, np);
|
|
227
|
+
if (s) return true;
|
|
228
|
+
return this.virtualChildrenAt(np).length > 0;
|
|
97
229
|
}
|
|
98
230
|
|
|
99
231
|
async stat(path: string): Promise<FsStat> {
|
|
100
232
|
const np = normalize(path);
|
|
233
|
+
const route = this.routeToMount(np);
|
|
234
|
+
if (route) {
|
|
235
|
+
try {
|
|
236
|
+
const s = await nodeFs.stat(this.toLocal(route.mount, route.relative));
|
|
237
|
+
return this.toFsStat(s);
|
|
238
|
+
} catch {
|
|
239
|
+
throw new Error(`ENOENT: no such file or directory, stat '${path}'`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
101
242
|
const s = await this.engine.vfs.stat(this.tenantId, np);
|
|
102
|
-
if (
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
243
|
+
if (s) {
|
|
244
|
+
return {
|
|
245
|
+
isFile: s.type === "file",
|
|
246
|
+
isDirectory: s.type === "directory",
|
|
247
|
+
isSymbolicLink: s.type === "symlink",
|
|
248
|
+
mode: s.mode,
|
|
249
|
+
size: s.size,
|
|
250
|
+
mtime: new Date(s.updatedAt),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
// Virtual ancestor directory (no real entry, but mounts beneath it).
|
|
254
|
+
if (this.virtualChildrenAt(np).length > 0) return this.syntheticDirStat();
|
|
255
|
+
throw new Error(`ENOENT: no such file or directory, stat '${path}'`);
|
|
111
256
|
}
|
|
112
257
|
|
|
113
258
|
async readdir(path: string): Promise<string[]> {
|
|
114
|
-
const
|
|
115
|
-
|
|
259
|
+
const np = normalize(path);
|
|
260
|
+
const route = this.routeToMount(np);
|
|
261
|
+
if (route) {
|
|
262
|
+
return nodeFs.readdir(this.toLocal(route.mount, route.relative));
|
|
263
|
+
}
|
|
264
|
+
// Engine-backed read; also inject any mount-root segments whose parent
|
|
265
|
+
// is this directory but which aren't real directories on the engine side.
|
|
266
|
+
let engineNames: string[] = [];
|
|
267
|
+
try {
|
|
268
|
+
const entries = await this.engine.vfs.readdir(this.tenantId, np);
|
|
269
|
+
engineNames = entries.map((e) => e.name);
|
|
270
|
+
} catch {
|
|
271
|
+
// Falls through: maybe this is a virtual-only directory (e.g. "/system"
|
|
272
|
+
// when there's no engine row for it but "/system/jobs/" is mounted).
|
|
273
|
+
}
|
|
274
|
+
const virtualSegs = this.virtualChildrenAt(np);
|
|
275
|
+
if (virtualSegs.length === 0) return engineNames;
|
|
276
|
+
const merged = new Set(engineNames);
|
|
277
|
+
for (const seg of virtualSegs) merged.add(seg);
|
|
278
|
+
return Array.from(merged);
|
|
116
279
|
}
|
|
117
280
|
|
|
118
281
|
async readdirWithFileTypes(path: string): Promise<Array<{ name: string; isFile: boolean; isDirectory: boolean; isSymbolicLink: boolean }>> {
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
282
|
+
const np = normalize(path);
|
|
283
|
+
const route = this.routeToMount(np);
|
|
284
|
+
if (route) {
|
|
285
|
+
const entries = await nodeFs.readdir(this.toLocal(route.mount, route.relative), { withFileTypes: true });
|
|
286
|
+
return entries.map((e) => ({
|
|
287
|
+
name: e.name,
|
|
288
|
+
isFile: e.isFile(),
|
|
289
|
+
isDirectory: e.isDirectory(),
|
|
290
|
+
isSymbolicLink: e.isSymbolicLink(),
|
|
291
|
+
}));
|
|
292
|
+
}
|
|
293
|
+
let engineEntries: Array<{ name: string; isFile: boolean; isDirectory: boolean; isSymbolicLink: boolean }> = [];
|
|
294
|
+
try {
|
|
295
|
+
const entries = await this.engine.vfs.readdir(this.tenantId, np);
|
|
296
|
+
engineEntries = entries.map((e) => ({
|
|
297
|
+
name: e.name,
|
|
298
|
+
isFile: e.type === "file",
|
|
299
|
+
isDirectory: e.type === "directory",
|
|
300
|
+
isSymbolicLink: e.type === "symlink",
|
|
301
|
+
}));
|
|
302
|
+
} catch {
|
|
303
|
+
// virtual-only directory, see readdir
|
|
304
|
+
}
|
|
305
|
+
const virtualSegs = this.virtualChildrenAt(np);
|
|
306
|
+
if (virtualSegs.length === 0) return engineEntries;
|
|
307
|
+
const seen = new Set(engineEntries.map((e) => e.name));
|
|
308
|
+
for (const seg of virtualSegs) {
|
|
309
|
+
if (!seen.has(seg)) {
|
|
310
|
+
engineEntries.push({ name: seg, isFile: false, isDirectory: true, isSymbolicLink: false });
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return engineEntries;
|
|
126
314
|
}
|
|
127
315
|
|
|
128
316
|
// --- Writes ---
|
|
@@ -132,6 +320,8 @@ export class PonchoFsAdapter implements IFileSystem {
|
|
|
132
320
|
content: FileContent,
|
|
133
321
|
_options?: { encoding?: BufferEncoding } | BufferEncoding,
|
|
134
322
|
): Promise<void> {
|
|
323
|
+
const np = normalize(path);
|
|
324
|
+
if (this.routeToMount(np)) throw READ_ONLY_ERROR(path, "writeFile");
|
|
135
325
|
const buf = typeof content === "string" ? new TextEncoder().encode(content) : content;
|
|
136
326
|
if (buf.byteLength > this.limits.maxFileSize) {
|
|
137
327
|
throw new Error(
|
|
@@ -139,7 +329,7 @@ export class PonchoFsAdapter implements IFileSystem {
|
|
|
139
329
|
);
|
|
140
330
|
}
|
|
141
331
|
const mime = mimeFromExtension(path);
|
|
142
|
-
await this.engine.vfs.writeFile(this.tenantId,
|
|
332
|
+
await this.engine.vfs.writeFile(this.tenantId, np, buf, mime);
|
|
143
333
|
}
|
|
144
334
|
|
|
145
335
|
async appendFile(
|
|
@@ -147,16 +337,21 @@ export class PonchoFsAdapter implements IFileSystem {
|
|
|
147
337
|
content: FileContent,
|
|
148
338
|
_options?: { encoding?: BufferEncoding } | BufferEncoding,
|
|
149
339
|
): Promise<void> {
|
|
340
|
+
const np = normalize(path);
|
|
341
|
+
if (this.routeToMount(np)) throw READ_ONLY_ERROR(path, "appendFile");
|
|
150
342
|
const buf = typeof content === "string" ? new TextEncoder().encode(content) : content;
|
|
151
|
-
await this.engine.vfs.appendFile(this.tenantId,
|
|
343
|
+
await this.engine.vfs.appendFile(this.tenantId, np, buf);
|
|
152
344
|
}
|
|
153
345
|
|
|
154
346
|
async mkdir(path: string, options?: MkdirOptions): Promise<void> {
|
|
155
|
-
|
|
347
|
+
const np = normalize(path);
|
|
348
|
+
if (this.routeToMount(np)) throw READ_ONLY_ERROR(path, "mkdir");
|
|
349
|
+
await this.engine.vfs.mkdir(this.tenantId, np, options?.recursive);
|
|
156
350
|
}
|
|
157
351
|
|
|
158
352
|
async rm(path: string, options?: RmOptions): Promise<void> {
|
|
159
353
|
const np = normalize(path);
|
|
354
|
+
if (this.routeToMount(np)) throw READ_ONLY_ERROR(path, "rm");
|
|
160
355
|
const s = await this.engine.vfs.stat(this.tenantId, np);
|
|
161
356
|
if (!s) {
|
|
162
357
|
if (options?.force) return;
|
|
@@ -174,27 +369,35 @@ export class PonchoFsAdapter implements IFileSystem {
|
|
|
174
369
|
async cp(src: string, dest: string, options?: CpOptions): Promise<void> {
|
|
175
370
|
const srcNorm = normalize(src);
|
|
176
371
|
const destNorm = normalize(dest);
|
|
177
|
-
|
|
372
|
+
if (this.routeToMount(destNorm)) throw READ_ONLY_ERROR(dest, "cp");
|
|
373
|
+
|
|
374
|
+
// Source may be either engine-backed or mount-backed. Route through this
|
|
375
|
+
// adapter's own read methods so reads from mounted paths work.
|
|
376
|
+
const srcStat = await this.stat(srcNorm).catch(() => null);
|
|
178
377
|
if (!srcStat) throw new Error(`ENOENT: no such file or directory, cp '${src}'`);
|
|
179
378
|
|
|
180
|
-
if (srcStat.
|
|
379
|
+
if (srcStat.isDirectory) {
|
|
181
380
|
if (!options?.recursive) {
|
|
182
381
|
throw new Error(`EISDIR: cp -r not specified; omitting directory '${src}'`);
|
|
183
382
|
}
|
|
184
383
|
await this.engine.vfs.mkdir(this.tenantId, destNorm, true);
|
|
185
|
-
const entries = await this.
|
|
186
|
-
for (const
|
|
187
|
-
await this.cp(`${srcNorm}/${
|
|
384
|
+
const entries = await this.readdir(srcNorm);
|
|
385
|
+
for (const name of entries) {
|
|
386
|
+
await this.cp(`${srcNorm}/${name}`, `${destNorm}/${name}`, options);
|
|
188
387
|
}
|
|
189
388
|
} else {
|
|
190
|
-
const content = await this.
|
|
389
|
+
const content = await this.readFileBuffer(srcNorm);
|
|
191
390
|
const mime = mimeFromExtension(destNorm);
|
|
192
391
|
await this.engine.vfs.writeFile(this.tenantId, destNorm, content, mime);
|
|
193
392
|
}
|
|
194
393
|
}
|
|
195
394
|
|
|
196
395
|
async mv(src: string, dest: string): Promise<void> {
|
|
197
|
-
|
|
396
|
+
const srcNorm = normalize(src);
|
|
397
|
+
const destNorm = normalize(dest);
|
|
398
|
+
if (this.routeToMount(srcNorm)) throw READ_ONLY_ERROR(src, "mv (source)");
|
|
399
|
+
if (this.routeToMount(destNorm)) throw READ_ONLY_ERROR(dest, "mv (dest)");
|
|
400
|
+
await this.engine.vfs.rename(this.tenantId, srcNorm, destNorm);
|
|
198
401
|
}
|
|
199
402
|
|
|
200
403
|
// --- Path resolution ---
|
|
@@ -206,9 +409,27 @@ export class PonchoFsAdapter implements IFileSystem {
|
|
|
206
409
|
|
|
207
410
|
async realpath(path: string): Promise<string> {
|
|
208
411
|
const np = normalize(path);
|
|
412
|
+
const route = this.routeToMount(np);
|
|
413
|
+
if (route) {
|
|
414
|
+
// Mount contents on local disk: resolve via node, but report back
|
|
415
|
+
// in VFS-namespace terms (don't leak the on-disk source path to the
|
|
416
|
+
// agent — that would be confusing and non-portable).
|
|
417
|
+
const localResolved = await nodeFs.realpath(this.toLocal(route.mount, route.relative));
|
|
418
|
+
const localRoot = await nodeFs.realpath(route.mount.source).catch(() => route.mount.source);
|
|
419
|
+
if (localResolved === localRoot) return route.mount.prefixNoSlash;
|
|
420
|
+
if (localResolved.startsWith(localRoot + nodePath.sep)) {
|
|
421
|
+
const rel = localResolved.slice(localRoot.length + 1).split(nodePath.sep).join("/");
|
|
422
|
+
return `${route.mount.prefix}${rel}`;
|
|
423
|
+
}
|
|
424
|
+
// Symlink escaped the mount — return the VFS path as-is.
|
|
425
|
+
return np;
|
|
426
|
+
}
|
|
209
427
|
// Resolve symlinks in the path
|
|
210
428
|
const s = await this.engine.vfs.lstat(this.tenantId, np);
|
|
211
|
-
if (!s)
|
|
429
|
+
if (!s) {
|
|
430
|
+
if (this.virtualChildrenAt(np).length > 0) return np;
|
|
431
|
+
throw new Error(`ENOENT: no such file or directory, realpath '${path}'`);
|
|
432
|
+
}
|
|
212
433
|
if (s.type === "symlink" && s.symlinkTarget) {
|
|
213
434
|
const target = s.symlinkTarget.startsWith("/")
|
|
214
435
|
? s.symlinkTarget
|
|
@@ -221,47 +442,108 @@ export class PonchoFsAdapter implements IFileSystem {
|
|
|
221
442
|
// --- Sync: required by just-bash for glob/find ---
|
|
222
443
|
|
|
223
444
|
getAllPaths(): string[] {
|
|
224
|
-
|
|
445
|
+
const enginePaths = this.engine.vfs.listAllPaths(this.tenantId);
|
|
446
|
+
if (this.mounts.length === 0) return enginePaths;
|
|
447
|
+
const out = new Set(enginePaths);
|
|
448
|
+
for (const m of this.mounts) {
|
|
449
|
+
// Always advertise the mount root itself as a directory.
|
|
450
|
+
out.add(m.prefixNoSlash);
|
|
451
|
+
// Walk the local source once and add all paths under the mount.
|
|
452
|
+
// Sync IO is acceptable here: bash glob/find call this sporadically and
|
|
453
|
+
// the source is a small static asset directory on the API container.
|
|
454
|
+
try {
|
|
455
|
+
const stack: Array<{ abs: string; vfs: string }> = [
|
|
456
|
+
{ abs: m.source, vfs: m.prefixNoSlash },
|
|
457
|
+
];
|
|
458
|
+
while (stack.length > 0) {
|
|
459
|
+
const { abs, vfs } = stack.pop()!;
|
|
460
|
+
let entries: nodeFsSync.Dirent[];
|
|
461
|
+
try {
|
|
462
|
+
entries = nodeFsSync.readdirSync(abs, { withFileTypes: true });
|
|
463
|
+
} catch {
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
for (const e of entries) {
|
|
467
|
+
const childVfs = `${vfs}/${e.name}`;
|
|
468
|
+
out.add(childVfs);
|
|
469
|
+
if (e.isDirectory()) {
|
|
470
|
+
stack.push({ abs: nodePath.join(abs, e.name), vfs: childVfs });
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
} catch {
|
|
475
|
+
// Source dir doesn't exist; skip it. Mount root is still advertised.
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return Array.from(out);
|
|
225
479
|
}
|
|
226
480
|
|
|
227
481
|
// --- Metadata ---
|
|
228
482
|
|
|
229
483
|
async chmod(path: string, mode: number): Promise<void> {
|
|
230
|
-
|
|
484
|
+
const np = normalize(path);
|
|
485
|
+
if (this.routeToMount(np)) throw READ_ONLY_ERROR(path, "chmod");
|
|
486
|
+
await this.engine.vfs.chmod(this.tenantId, np, mode);
|
|
231
487
|
}
|
|
232
488
|
|
|
233
489
|
async utimes(path: string, _atime: Date, mtime: Date): Promise<void> {
|
|
234
|
-
|
|
490
|
+
const np = normalize(path);
|
|
491
|
+
if (this.routeToMount(np)) throw READ_ONLY_ERROR(path, "utimes");
|
|
492
|
+
await this.engine.vfs.utimes(this.tenantId, np, mtime);
|
|
235
493
|
}
|
|
236
494
|
|
|
237
495
|
// --- Symlinks ---
|
|
238
496
|
|
|
239
497
|
async symlink(target: string, linkPath: string): Promise<void> {
|
|
240
|
-
|
|
498
|
+
const np = normalize(linkPath);
|
|
499
|
+
if (this.routeToMount(np)) throw READ_ONLY_ERROR(linkPath, "symlink");
|
|
500
|
+
await this.engine.vfs.symlink(this.tenantId, target, np);
|
|
241
501
|
}
|
|
242
502
|
|
|
243
503
|
async link(existingPath: string, newPath: string): Promise<void> {
|
|
244
|
-
|
|
245
|
-
|
|
504
|
+
const npNew = normalize(newPath);
|
|
505
|
+
if (this.routeToMount(npNew)) throw READ_ONLY_ERROR(newPath, "link");
|
|
506
|
+
// Hard link: copy content. Source may be mount-backed, so route through
|
|
507
|
+
// this adapter's own read path.
|
|
508
|
+
const content = await this.readFileBuffer(existingPath);
|
|
246
509
|
const mime = mimeFromExtension(newPath);
|
|
247
|
-
await this.engine.vfs.writeFile(this.tenantId,
|
|
510
|
+
await this.engine.vfs.writeFile(this.tenantId, npNew, content, mime);
|
|
248
511
|
}
|
|
249
512
|
|
|
250
513
|
async readlink(path: string): Promise<string> {
|
|
251
|
-
|
|
514
|
+
const np = normalize(path);
|
|
515
|
+
const route = this.routeToMount(np);
|
|
516
|
+
if (route) {
|
|
517
|
+
// Mount contents are real files; readlink only makes sense for symlinks
|
|
518
|
+
// we don't expect to have on disk. Node will throw EINVAL for non-links.
|
|
519
|
+
return nodeFs.readlink(this.toLocal(route.mount, route.relative));
|
|
520
|
+
}
|
|
521
|
+
return this.engine.vfs.readlink(this.tenantId, np);
|
|
252
522
|
}
|
|
253
523
|
|
|
254
524
|
async lstat(path: string): Promise<FsStat> {
|
|
255
525
|
const np = normalize(path);
|
|
526
|
+
const route = this.routeToMount(np);
|
|
527
|
+
if (route) {
|
|
528
|
+
try {
|
|
529
|
+
const s = await nodeFs.lstat(this.toLocal(route.mount, route.relative));
|
|
530
|
+
return this.toFsStat(s);
|
|
531
|
+
} catch {
|
|
532
|
+
throw new Error(`ENOENT: no such file or directory, lstat '${path}'`);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
256
535
|
const s = await this.engine.vfs.lstat(this.tenantId, np);
|
|
257
|
-
if (
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
536
|
+
if (s) {
|
|
537
|
+
return {
|
|
538
|
+
isFile: s.type === "file",
|
|
539
|
+
isDirectory: s.type === "directory",
|
|
540
|
+
isSymbolicLink: s.type === "symlink",
|
|
541
|
+
mode: s.mode,
|
|
542
|
+
size: s.size,
|
|
543
|
+
mtime: new Date(s.updatedAt),
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
if (this.virtualChildrenAt(np).length > 0) return this.syntheticDirStat();
|
|
547
|
+
throw new Error(`ENOENT: no such file or directory, lstat '${path}'`);
|
|
266
548
|
}
|
|
267
549
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { decodeFileInputData } from "../src/upload-store.js";
|
|
3
|
+
|
|
4
|
+
// FileInput.data is documented in @poncho-ai/sdk as accepting three formats:
|
|
5
|
+
// raw base64, `data:<mime>;base64,<…>` URIs, and `http(s)://…` URLs. The
|
|
6
|
+
// runtime used to call `Buffer.from(data, "base64")` unconditionally, which
|
|
7
|
+
// silently produced garbage bytes for data URIs (Node's base64 decoder
|
|
8
|
+
// ignores invalid chars like `:` `;` `,` rather than throwing, so the JPEG
|
|
9
|
+
// magic bytes were destroyed and Anthropic responded with "Could not
|
|
10
|
+
// process image"). These tests pin the contract so it can't regress.
|
|
11
|
+
|
|
12
|
+
// 1x1 transparent PNG, base64-encoded.
|
|
13
|
+
const PNG_BASE64 =
|
|
14
|
+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
|
|
15
|
+
const PNG_FIRST_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47]); // ‰PNG
|
|
16
|
+
|
|
17
|
+
describe("decodeFileInputData", () => {
|
|
18
|
+
it("decodes raw base64", async () => {
|
|
19
|
+
const out = await decodeFileInputData(PNG_BASE64);
|
|
20
|
+
expect(out.subarray(0, 4).equals(PNG_FIRST_BYTES)).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("decodes a data: URI", async () => {
|
|
24
|
+
const out = await decodeFileInputData(`data:image/png;base64,${PNG_BASE64}`);
|
|
25
|
+
expect(out.subarray(0, 4).equals(PNG_FIRST_BYTES)).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("decodes a data: URI even when the mime has parameters", async () => {
|
|
29
|
+
// The contract allows arbitrary `data:<anything>;base64,` prefixes — only
|
|
30
|
+
// the `;base64,` part is load-bearing. Used in the wild for things like
|
|
31
|
+
// `data:image/svg+xml;base64,...`.
|
|
32
|
+
const out = await decodeFileInputData(
|
|
33
|
+
`data:image/svg+xml;charset=utf-8;base64,${PNG_BASE64}`,
|
|
34
|
+
);
|
|
35
|
+
expect(out.subarray(0, 4).equals(PNG_FIRST_BYTES)).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("decoded bytes from raw base64 and data URI match", async () => {
|
|
39
|
+
const raw = await decodeFileInputData(PNG_BASE64);
|
|
40
|
+
const uri = await decodeFileInputData(`data:image/png;base64,${PNG_BASE64}`);
|
|
41
|
+
expect(raw.equals(uri)).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
});
|
package/test/vfs.test.ts
CHANGED
|
@@ -240,3 +240,114 @@ describe("bash + VFS integration", () => {
|
|
|
240
240
|
await engine.close();
|
|
241
241
|
});
|
|
242
242
|
});
|
|
243
|
+
|
|
244
|
+
describe("PonchoFsAdapter virtual read-only mounts", () => {
|
|
245
|
+
async function withMountFixture(): Promise<{
|
|
246
|
+
engine: InMemoryEngine;
|
|
247
|
+
adapter: PonchoFsAdapter;
|
|
248
|
+
sourceDir: string;
|
|
249
|
+
cleanup: () => Promise<void>;
|
|
250
|
+
}> {
|
|
251
|
+
const nodeFs = await import("node:fs/promises");
|
|
252
|
+
const nodeOs = await import("node:os");
|
|
253
|
+
const nodePath = await import("node:path");
|
|
254
|
+
const sourceDir = await nodeFs.mkdtemp(nodePath.join(nodeOs.tmpdir(), "ponchofs-mount-"));
|
|
255
|
+
await nodeFs.mkdir(nodePath.join(sourceDir, "jobs"), { recursive: true });
|
|
256
|
+
await nodeFs.writeFile(nodePath.join(sourceDir, "jobs", "dream.md"), "dream content");
|
|
257
|
+
await nodeFs.writeFile(nodePath.join(sourceDir, "jobs", "heartbeat.md"), "heartbeat content");
|
|
258
|
+
|
|
259
|
+
const engine = new InMemoryEngine("test");
|
|
260
|
+
await engine.initialize();
|
|
261
|
+
const adapter = new PonchoFsAdapter(engine, "t1", LIMITS, [
|
|
262
|
+
{ prefix: "/system/", source: sourceDir },
|
|
263
|
+
]);
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
engine,
|
|
267
|
+
adapter,
|
|
268
|
+
sourceDir,
|
|
269
|
+
cleanup: async () => {
|
|
270
|
+
await engine.close();
|
|
271
|
+
await nodeFs.rm(sourceDir, { recursive: true, force: true });
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
it("serves mounted files from disk and lists them via readdir", async () => {
|
|
277
|
+
const { adapter, cleanup } = await withMountFixture();
|
|
278
|
+
try {
|
|
279
|
+
expect(await adapter.readFile("/system/jobs/dream.md")).toBe("dream content");
|
|
280
|
+
expect(await adapter.readFile("/system/jobs/heartbeat.md")).toBe("heartbeat content");
|
|
281
|
+
|
|
282
|
+
const sysEntries = (await adapter.readdir("/system")).sort();
|
|
283
|
+
expect(sysEntries).toEqual(["jobs"]);
|
|
284
|
+
|
|
285
|
+
const jobsEntries = (await adapter.readdir("/system/jobs")).sort();
|
|
286
|
+
expect(jobsEntries).toEqual(["dream.md", "heartbeat.md"]);
|
|
287
|
+
|
|
288
|
+
const dreamStat = await adapter.stat("/system/jobs/dream.md");
|
|
289
|
+
expect(dreamStat.isFile).toBe(true);
|
|
290
|
+
expect(dreamStat.size).toBe("dream content".length);
|
|
291
|
+
|
|
292
|
+
const sysStat = await adapter.stat("/system");
|
|
293
|
+
expect(sysStat.isDirectory).toBe(true);
|
|
294
|
+
} finally {
|
|
295
|
+
await cleanup();
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("exposes the mount root segment when listing the root", async () => {
|
|
300
|
+
const { adapter, cleanup } = await withMountFixture();
|
|
301
|
+
try {
|
|
302
|
+
// Write a user file at /jobs/ so root has both engine + virtual entries.
|
|
303
|
+
await adapter.mkdir("/jobs", { recursive: true });
|
|
304
|
+
await adapter.writeFile("/jobs/mine.md", "mine");
|
|
305
|
+
const rootEntries = (await adapter.readdir("/")).sort();
|
|
306
|
+
expect(rootEntries).toContain("jobs");
|
|
307
|
+
expect(rootEntries).toContain("system");
|
|
308
|
+
} finally {
|
|
309
|
+
await cleanup();
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("rejects writes anywhere under a mount prefix", async () => {
|
|
314
|
+
const { adapter, cleanup } = await withMountFixture();
|
|
315
|
+
try {
|
|
316
|
+
await expect(adapter.writeFile("/system/jobs/new.md", "x")).rejects.toThrow(/EROFS/);
|
|
317
|
+
await expect(adapter.writeFile("/system/jobs/dream.md", "overwrite")).rejects.toThrow(/EROFS/);
|
|
318
|
+
await expect(adapter.mkdir("/system/extra", { recursive: true })).rejects.toThrow(/EROFS/);
|
|
319
|
+
await expect(adapter.rm("/system/jobs/dream.md")).rejects.toThrow(/EROFS/);
|
|
320
|
+
await expect(adapter.appendFile("/system/jobs/dream.md", "y")).rejects.toThrow(/EROFS/);
|
|
321
|
+
// Source of mv being mounted also rejects (can't move out of read-only).
|
|
322
|
+
await expect(adapter.mv("/system/jobs/dream.md", "/jobs/dream.md")).rejects.toThrow(/EROFS/);
|
|
323
|
+
} finally {
|
|
324
|
+
await cleanup();
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("getAllPaths includes mount contents for bash glob/find", async () => {
|
|
329
|
+
const { adapter, cleanup } = await withMountFixture();
|
|
330
|
+
try {
|
|
331
|
+
const paths = adapter.getAllPaths();
|
|
332
|
+
expect(paths).toContain("/system");
|
|
333
|
+
expect(paths).toContain("/system/jobs");
|
|
334
|
+
expect(paths).toContain("/system/jobs/dream.md");
|
|
335
|
+
expect(paths).toContain("/system/jobs/heartbeat.md");
|
|
336
|
+
} finally {
|
|
337
|
+
await cleanup();
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("does not affect engine-backed reads outside any mount", async () => {
|
|
342
|
+
const { adapter, cleanup } = await withMountFixture();
|
|
343
|
+
try {
|
|
344
|
+
await adapter.mkdir("/jobs", { recursive: true });
|
|
345
|
+
await adapter.writeFile("/jobs/morning-brief.md", "brief");
|
|
346
|
+
expect(await adapter.readFile("/jobs/morning-brief.md")).toBe("brief");
|
|
347
|
+
// Mount path still serves from disk independently
|
|
348
|
+
expect(await adapter.readFile("/system/jobs/dream.md")).toBe("dream content");
|
|
349
|
+
} finally {
|
|
350
|
+
await cleanup();
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
});
|