@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.
@@ -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 buf = await this.engine.vfs.readFile(this.tenantId, normalize(path));
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
- return this.engine.vfs.readFile(this.tenantId, normalize(path));
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 s = await this.engine.vfs.stat(this.tenantId, normalize(path));
96
- return s !== undefined;
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 (!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
- };
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 entries = await this.engine.vfs.readdir(this.tenantId, normalize(path));
115
- return entries.map((e) => e.name);
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 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
- }));
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, normalize(path), buf, mime);
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, normalize(path), buf);
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
- await this.engine.vfs.mkdir(this.tenantId, normalize(path), options?.recursive);
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
- const srcStat = await this.engine.vfs.stat(this.tenantId, srcNorm);
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.type === "directory") {
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.engine.vfs.readdir(this.tenantId, srcNorm);
186
- for (const entry of entries) {
187
- await this.cp(`${srcNorm}/${entry.name}`, `${destNorm}/${entry.name}`, options);
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.engine.vfs.readFile(this.tenantId, srcNorm);
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
- await this.engine.vfs.rename(this.tenantId, normalize(src), normalize(dest));
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) throw new Error(`ENOENT: no such file or directory, realpath '${path}'`);
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
- return this.engine.vfs.listAllPaths(this.tenantId);
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
- await this.engine.vfs.chmod(this.tenantId, normalize(path), mode);
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
- await this.engine.vfs.utimes(this.tenantId, normalize(path), mtime);
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
- await this.engine.vfs.symlink(this.tenantId, target, normalize(linkPath));
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
- // Hard link: copy content
245
- const content = await this.engine.vfs.readFile(this.tenantId, normalize(existingPath));
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, normalize(newPath), content, mime);
510
+ await this.engine.vfs.writeFile(this.tenantId, npNew, content, mime);
248
511
  }
249
512
 
250
513
  async readlink(path: string): Promise<string> {
251
- return this.engine.vfs.readlink(this.tenantId, normalize(path));
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 (!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
- };
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
+ });