pi-multimodal-proxy 1.6.0 → 1.7.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.
@@ -8,8 +8,8 @@
8
8
  */
9
9
 
10
10
  import { strict as assert } from "node:assert";
11
- import { describe, it } from "node:test";
12
- import { mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
11
+ import { after, describe, it } from "node:test";
12
+ import { lstat, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
13
13
  import os from "node:os";
14
14
  import { join, parse } from "node:path";
15
15
  import {
@@ -59,6 +59,7 @@ import {
59
59
  hammingDistance,
60
60
  computePHash,
61
61
  cropImage,
62
+ shutdownCropWorkers,
62
63
  piAiImageToBuffer,
63
64
  bufferToPiAiImage,
64
65
  shouldStripImages,
@@ -70,7 +71,15 @@ import {
70
71
  writePersistentFile,
71
72
  sanitizeForLog,
72
73
  storeImageMeta,
73
- _imageMeta,
74
+ createImageMetaStore,
75
+ storeImageData,
76
+ getImageData,
77
+ createImageDataStore,
78
+ parseRecallRef,
79
+ spinnerFrame,
80
+ formatProgressStatus,
81
+ SPINNER_FRAMES,
82
+ RECALL_HINT,
74
83
  } from "../internal.ts";
75
84
 
76
85
  // SessionEntry minimal shape — typed loose because peer dep types are not loaded in test
@@ -285,6 +294,152 @@ describe("pluralImages", () => {
285
294
  });
286
295
  });
287
296
 
297
+ describe("parseRecallRef", () => {
298
+ const hash = "a".repeat(32);
299
+
300
+ it("accepts a bare 32-hex hash", () => {
301
+ assert.equal(parseRecallRef(hash), hash);
302
+ });
303
+
304
+ it("accepts a sha256:-prefixed hash", () => {
305
+ assert.equal(parseRecallRef(`sha256:${hash}`), hash);
306
+ });
307
+
308
+ it("strips a #crop suffix and recalls the base image", () => {
309
+ assert.equal(parseRecallRef(`${hash}#crop:1840,120,840,360`), hash);
310
+ });
311
+
312
+ it("normalizes uppercase to lowercase", () => {
313
+ assert.equal(parseRecallRef("A".repeat(32)), hash);
314
+ });
315
+
316
+ it("rejects file paths and non-hash refs", () => {
317
+ assert.equal(parseRecallRef("/tmp/shot.png"), null);
318
+ assert.equal(parseRecallRef("./a.png"), null);
319
+ assert.equal(parseRecallRef("screenshot.png"), null);
320
+ // Wrong length / non-hex
321
+ assert.equal(parseRecallRef("a".repeat(31)), null);
322
+ assert.equal(parseRecallRef("a".repeat(33)), null);
323
+ assert.equal(parseRecallRef("z".repeat(32)), null);
324
+ });
325
+ });
326
+
327
+ describe("session image recall store", () => {
328
+ it("round-trips retained image bytes by hash", () => {
329
+ const store = createImageDataStore();
330
+ storeImageData(store, "hash1", "AAAA", "image/png");
331
+ const got = getImageData(store, "hash1");
332
+ assert.deepEqual(got, { data: "AAAA", mimeType: "image/png" });
333
+ assert.equal(getImageData(store, "missing"), undefined);
334
+ });
335
+
336
+ it("ignores empty hash or data", () => {
337
+ const store = createImageDataStore();
338
+ storeImageData(store, "", "AAAA", "image/png");
339
+ storeImageData(store, "hash", "", "image/png");
340
+ assert.equal(store.map.size, 0);
341
+ });
342
+
343
+ it("does not duplicate on re-store of the same hash", () => {
344
+ const store = createImageDataStore();
345
+ storeImageData(store, "hash1", "AAAA", "image/png");
346
+ storeImageData(store, "hash1", "AAAA", "image/png");
347
+ assert.equal(store.map.size, 1);
348
+ });
349
+
350
+ it("keeps stores isolated — one session's bytes do not leak into another (issue #12)", () => {
351
+ const sessionA = createImageDataStore();
352
+ const sessionB = createImageDataStore();
353
+ storeImageData(sessionA, "hash1", "AAAA", "image/png");
354
+ assert.deepEqual(getImageData(sessionA, "hash1"), { data: "AAAA", mimeType: "image/png" });
355
+ assert.equal(getImageData(sessionB, "hash1"), undefined);
356
+ });
357
+
358
+ it("evicts least-recently-used entries past the byte budget", () => {
359
+ const store = createImageDataStore();
360
+ const prev = process.env.PI_VISION_PROXY_IMAGE_RECALL_BYTES;
361
+ // Budget of 10 decoded bytes; each 8-char base64 entry decodes to 6 bytes.
362
+ process.env.PI_VISION_PROXY_IMAGE_RECALL_BYTES = "10";
363
+ try {
364
+ storeImageData(store, "h1", "AAAAAAAA", "image/png"); // 6 decoded bytes, total 6
365
+ storeImageData(store, "h2", "BBBBBBBB", "image/png"); // 6 decoded bytes, total 12 > 10 → evict h1
366
+ assert.equal(getImageData(store, "h1"), undefined);
367
+ assert.deepEqual(getImageData(store, "h2"), { data: "BBBBBBBB", mimeType: "image/png" });
368
+ } finally {
369
+ if (prev === undefined) delete process.env.PI_VISION_PROXY_IMAGE_RECALL_BYTES;
370
+ else process.env.PI_VISION_PROXY_IMAGE_RECALL_BYTES = prev;
371
+ }
372
+ });
373
+
374
+ it("keeps a single oversized image rather than evicting everything", () => {
375
+ const store = createImageDataStore();
376
+ const prev = process.env.PI_VISION_PROXY_IMAGE_RECALL_BYTES;
377
+ process.env.PI_VISION_PROXY_IMAGE_RECALL_BYTES = "4";
378
+ try {
379
+ storeImageData(store, "big", "AAAAAAAAAAAA", "image/png"); // 9 decoded bytes > 4 budget
380
+ assert.deepEqual(getImageData(store, "big"), { data: "AAAAAAAAAAAA", mimeType: "image/png" });
381
+ assert.equal(store.map.size, 1);
382
+ } finally {
383
+ if (prev === undefined) delete process.env.PI_VISION_PROXY_IMAGE_RECALL_BYTES;
384
+ else process.env.PI_VISION_PROXY_IMAGE_RECALL_BYTES = prev;
385
+ }
386
+ });
387
+
388
+ it("bumps recency on access so the touched entry survives eviction", () => {
389
+ const store = createImageDataStore();
390
+ const prev = process.env.PI_VISION_PROXY_IMAGE_RECALL_BYTES;
391
+ // Budget of 14 decoded bytes; each 8-char base64 entry decodes to 6 bytes.
392
+ process.env.PI_VISION_PROXY_IMAGE_RECALL_BYTES = "14";
393
+ try {
394
+ storeImageData(store, "h1", "AAAAAAAA", "image/png"); // 6 decoded bytes, total 6
395
+ storeImageData(store, "h2", "BBBBBBBB", "image/png"); // 6 decoded bytes, total 12
396
+ getImageData(store, "h1"); // bump h1 to most-recent
397
+ storeImageData(store, "h3", "CCCCCCCC", "image/png"); // 6 decoded bytes, total 18 > 14 → evict LRU (h2)
398
+ assert.deepEqual(getImageData(store, "h1"), { data: "AAAAAAAA", mimeType: "image/png" });
399
+ assert.equal(getImageData(store, "h2"), undefined);
400
+ assert.deepEqual(getImageData(store, "h3"), { data: "CCCCCCCC", mimeType: "image/png" });
401
+ } finally {
402
+ if (prev === undefined) delete process.env.PI_VISION_PROXY_IMAGE_RECALL_BYTES;
403
+ else process.env.PI_VISION_PROXY_IMAGE_RECALL_BYTES = prev;
404
+ }
405
+ });
406
+ });
407
+
408
+ describe("spinnerFrame", () => {
409
+ it("returns a frame from the set and wraps around", () => {
410
+ assert.equal(spinnerFrame(0), SPINNER_FRAMES[0]);
411
+ assert.equal(spinnerFrame(SPINNER_FRAMES.length), SPINNER_FRAMES[0]);
412
+ assert.equal(spinnerFrame(SPINNER_FRAMES.length + 1), SPINNER_FRAMES[1]);
413
+ assert.ok(SPINNER_FRAMES.includes(spinnerFrame(7)));
414
+ });
415
+
416
+ it("handles negative ticks without throwing", () => {
417
+ assert.ok(SPINNER_FRAMES.includes(spinnerFrame(-1)));
418
+ assert.ok(SPINNER_FRAMES.includes(spinnerFrame(-13)));
419
+ });
420
+ });
421
+
422
+ describe("formatProgressStatus", () => {
423
+ it("includes frame, label, and elapsed seconds", () => {
424
+ assert.equal(
425
+ formatProgressStatus("Analyzing image 2/4…", "⠙", 3),
426
+ "multimodal-proxy ⠙ Analyzing image 2/4… (3s)",
427
+ );
428
+ });
429
+
430
+ it("floors and clamps elapsed seconds", () => {
431
+ assert.match(formatProgressStatus("x", "⠋", 4.9), /\(4s\)$/);
432
+ assert.match(formatProgressStatus("x", "⠋", -2), /\(0s\)$/);
433
+ });
434
+ });
435
+
436
+ describe("RECALL_HINT", () => {
437
+ it("mentions analyze_image and the image id", () => {
438
+ assert.match(RECALL_HINT, /analyze_image/);
439
+ assert.match(RECALL_HINT, /image id/i);
440
+ });
441
+ });
442
+
288
443
  describe("splitSubcommand", () => {
289
444
  it("splits sub and value with arbitrary whitespace", () => {
290
445
  assert.deepEqual(splitSubcommand("model anthropic/claude"), { sub: "model", value: "anthropic/claude" });
@@ -591,8 +746,30 @@ describe("isPathAllowed", () => {
591
746
  }
592
747
  });
593
748
 
594
- it("denies non-existent files", async () => {
595
- assert.equal(await isPathAllowed(join(os.tmpdir(), "does-not-exist-xyz.png")), false);
749
+ it("allows non-existent files whose parent directory is in the allow-list", async () => {
750
+ // Non-existent files in tmpdir pass the check so callers can return "unreadable"
751
+ // instead of the misleading "denied" / "path outside allowed directories" message.
752
+ assert.equal(await isPathAllowed(join(os.tmpdir(), "does-not-exist-xyz.png")), true);
753
+ });
754
+
755
+ it("denies non-existent files outside the allow-list", async () => {
756
+ assert.equal(await isPathAllowed("/etc/does-not-exist-vp-xyz.png"), false);
757
+ });
758
+
759
+ it("allows files inside /tmp (system-wide Unix temp dir)", async () => {
760
+ if (os.platform() === "win32") return;
761
+ const file = join("/tmp", `vp-test-direct-${Date.now()}.png`);
762
+ await writeFile(file, TINY_PNG);
763
+ try {
764
+ assert.equal(await isPathAllowed(file), true);
765
+ } finally {
766
+ try { await rm(file); } catch { /* ignore */ }
767
+ }
768
+ });
769
+
770
+ it("allows non-existent files inside /tmp", async () => {
771
+ if (os.platform() === "win32") return;
772
+ assert.equal(await isPathAllowed("/tmp/does-not-exist-vp-xyz.png"), true);
596
773
  });
597
774
 
598
775
  it("allows local Windows drive paths by default", async () => {
@@ -669,13 +846,20 @@ describe("readImageFileWithReason", () => {
669
846
  });
670
847
 
671
848
  it("returns reason=denied for path outside allow-list", async () => {
672
- // /etc/passwd.png does not exist but extension is image-like.
673
- // realpath fails → denied. Either reason is acceptable in that order; assert non-null reason.
674
849
  const r = await readImageFileWithReason("/etc/never-exists-vp.png");
675
850
  assert.equal(r.image, null);
676
851
  assert.equal(r.reason, "denied");
677
852
  });
678
853
 
854
+ it("returns reason=unreadable for non-existent file inside /tmp", async () => {
855
+ if (os.platform() === "win32") return;
856
+ // Previously returned "denied" (misleading); now returns "unreadable" so the user
857
+ // knows the file is simply missing, not that /tmp itself is forbidden.
858
+ const r = await readImageFileWithReason("/tmp/does-not-exist-vp-xyz.png");
859
+ assert.equal(r.image, null);
860
+ assert.equal(r.reason, "unreadable");
861
+ });
862
+
679
863
  it("returns reason=empty for zero-byte image", async () => {
680
864
  const dir = await mkdtemp(join(os.tmpdir(), "vp-test-"));
681
865
  const file = join(dir, "empty.png");
@@ -724,6 +908,34 @@ describe("readImageFileWithReason", () => {
724
908
  await rm(dir, { recursive: true, force: true });
725
909
  }
726
910
  });
911
+
912
+ it("denies symlink to existing file outside allow-list (TOCTOU post-read check)", async () => {
913
+ // Simulate the TOCTOU race: the symlink target exists and is readable, so
914
+ // readFile() succeeds, but the post-read realpath re-verification must catch that
915
+ // the resolved path is outside the allow-list and return "denied".
916
+ if (os.platform() === "win32") return;
917
+ // Find a readable file outside the allow-list to use as a target.
918
+ // /etc/hostname is present on most Unix systems; use it if it exists.
919
+ const target = "/etc/hostname";
920
+ let targetExists = false;
921
+ try { await lstat(target); targetExists = true; } catch { /* skip if absent */ }
922
+ if (!targetExists) return;
923
+
924
+ const dir = await mkdtemp(join(os.tmpdir(), "vp-test-"));
925
+ const link = join(dir, "link.png");
926
+ try {
927
+ try {
928
+ await symlink(target, link);
929
+ } catch {
930
+ return; // no symlink support → skip
931
+ }
932
+ const r = await readImageFileWithReason(link);
933
+ assert.equal(r.image, null);
934
+ assert.equal(r.reason, "denied");
935
+ } finally {
936
+ await rm(dir, { recursive: true, force: true });
937
+ }
938
+ });
727
939
  });
728
940
 
729
941
  describe("readPersistentFile / writePersistentFile", () => {
@@ -1321,6 +1533,10 @@ async function create10x10Png(): Promise<Buffer> {
1321
1533
  }
1322
1534
 
1323
1535
  describe("cropImage (ImageScript)", () => {
1536
+ after(async () => {
1537
+ await shutdownCropWorkers();
1538
+ });
1539
+
1324
1540
  it("crops a 10×10 PNG to a 5×5 region", async () => {
1325
1541
  const png = await create10x10Png();
1326
1542
  const crop = { x: 2, y: 3, width: 5, height: 5 };
@@ -1355,6 +1571,92 @@ describe("cropImage (ImageScript)", () => {
1355
1571
  assert.equal(result[0], 0xff);
1356
1572
  assert.equal(result[1], 0xd8);
1357
1573
  });
1574
+
1575
+ it("succeeds within a generous decode timeout (env override is honoured)", async () => {
1576
+ const prev = process.env.PI_VISION_PROXY_DECODE_TIMEOUT_MS;
1577
+ process.env.PI_VISION_PROXY_DECODE_TIMEOUT_MS = "10000";
1578
+ try {
1579
+ const png = await create10x10Png();
1580
+ const result = await cropImage(png, { x: 0, y: 0, width: 10, height: 10 }, "image/png");
1581
+ assert.ok(result, "crop should succeed with a generous timeout");
1582
+ } finally {
1583
+ if (prev === undefined) delete process.env.PI_VISION_PROXY_DECODE_TIMEOUT_MS;
1584
+ else process.env.PI_VISION_PROXY_DECODE_TIMEOUT_MS = prev;
1585
+ }
1586
+ });
1587
+
1588
+ it("returns null (no throw) for undecodable garbage bytes", async () => {
1589
+ const garbage = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05]);
1590
+ const result = await cropImage(garbage, { x: 0, y: 0, width: 5, height: 5 }, "image/png");
1591
+ assert.equal(result, null);
1592
+ });
1593
+
1594
+ it("falls back to the in-thread path when the worker is disabled", async () => {
1595
+ const prev = process.env.PI_VISION_PROXY_DECODE_WORKER;
1596
+ process.env.PI_VISION_PROXY_DECODE_WORKER = "0";
1597
+ try {
1598
+ const png = await create10x10Png();
1599
+ const result = await cropImage(png, { x: 2, y: 3, width: 5, height: 5 }, "image/png");
1600
+ assert.ok(result, "in-thread fallback should still crop");
1601
+ const dims = extractDimensions(result);
1602
+ assert.ok(dims && dims.width === 5 && dims.height === 5, "fallback crop should have correct dims");
1603
+ } finally {
1604
+ if (prev === undefined) delete process.env.PI_VISION_PROXY_DECODE_WORKER;
1605
+ else process.env.PI_VISION_PROXY_DECODE_WORKER = prev;
1606
+ }
1607
+ });
1608
+
1609
+ it("handles several sequential crops (worker pool reuse)", async () => {
1610
+ const png = await create10x10Png();
1611
+ for (let i = 0; i < 5; i++) {
1612
+ const result = await cropImage(png, { x: 0, y: 0, width: 5, height: 5 }, "image/png");
1613
+ assert.ok(result, `crop ${i} should succeed`);
1614
+ const dims = extractDimensions(result);
1615
+ assert.ok(dims && dims.width === 5 && dims.height === 5, `crop ${i} dims`);
1616
+ }
1617
+ });
1618
+
1619
+ it("works with pooling disabled (spawn-per-call)", async () => {
1620
+ const prev = process.env.PI_VISION_PROXY_DECODE_WORKER_POOL;
1621
+ process.env.PI_VISION_PROXY_DECODE_WORKER_POOL = "0";
1622
+ try {
1623
+ const png = await create10x10Png();
1624
+ const a = await cropImage(png, { x: 0, y: 0, width: 5, height: 5 }, "image/png");
1625
+ const b = await cropImage(png, { x: 1, y: 1, width: 4, height: 4 }, "image/png");
1626
+ assert.ok(a && b, "both crops should succeed without pooling");
1627
+ } finally {
1628
+ if (prev === undefined) delete process.env.PI_VISION_PROXY_DECODE_WORKER_POOL;
1629
+ else process.env.PI_VISION_PROXY_DECODE_WORKER_POOL = prev;
1630
+ }
1631
+ });
1632
+
1633
+ it("hard-terminates the worker on timeout (returns null, does not hang)", async () => {
1634
+ // Force pool=0 so a *fresh* worker is spawned (never a warmed pooled one).
1635
+ // Spinning up a thread + loading ImageScript + instantiating the WASM codec
1636
+ // is reliably far slower than the 1ms timeout regardless of machine speed,
1637
+ // so the main-thread timer fires and terminate()s the worker — proving the
1638
+ // timeout is a hard limit, without depending on wall-clock scheduling luck.
1639
+ const prevWorker = process.env.PI_VISION_PROXY_DECODE_WORKER;
1640
+ const prevPool = process.env.PI_VISION_PROXY_DECODE_WORKER_POOL;
1641
+ const prevTimeout = process.env.PI_VISION_PROXY_DECODE_TIMEOUT_MS;
1642
+ process.env.PI_VISION_PROXY_DECODE_WORKER = "1";
1643
+ process.env.PI_VISION_PROXY_DECODE_WORKER_POOL = "0";
1644
+ process.env.PI_VISION_PROXY_DECODE_TIMEOUT_MS = "1";
1645
+ try {
1646
+ const png = await create10x10Png();
1647
+ const started = Date.now();
1648
+ const result = await cropImage(png, { x: 0, y: 0, width: 10, height: 10 }, "image/png");
1649
+ assert.equal(result, null, "timed-out crop should return null");
1650
+ assert.ok(Date.now() - started < 5000, "should return promptly, not hang");
1651
+ } finally {
1652
+ if (prevWorker === undefined) delete process.env.PI_VISION_PROXY_DECODE_WORKER;
1653
+ else process.env.PI_VISION_PROXY_DECODE_WORKER = prevWorker;
1654
+ if (prevPool === undefined) delete process.env.PI_VISION_PROXY_DECODE_WORKER_POOL;
1655
+ else process.env.PI_VISION_PROXY_DECODE_WORKER_POOL = prevPool;
1656
+ if (prevTimeout === undefined) delete process.env.PI_VISION_PROXY_DECODE_TIMEOUT_MS;
1657
+ else process.env.PI_VISION_PROXY_DECODE_TIMEOUT_MS = prevTimeout;
1658
+ }
1659
+ });
1358
1660
  });
1359
1661
 
1360
1662
  describe("piAiImageToBuffer / bufferToPiAiImage", () => {
@@ -1958,8 +2260,9 @@ describe("Security: image decode bomb protection", () => {
1958
2260
  const img = new Image(100, 100);
1959
2261
  const encoded = Buffer.from(await img.encode(1));
1960
2262
  const hash = "test-decode-bomb-normal";
1961
- storeImageMeta(hash, encoded);
1962
- const meta = _imageMeta.get(hash);
2263
+ const store = createImageMetaStore();
2264
+ storeImageMeta(store, hash, encoded);
2265
+ const meta = store.get(hash);
1963
2266
  // Normal image should be accepted
1964
2267
  assert.ok(meta, "normal image should be stored");
1965
2268
  });
@@ -2050,10 +2353,21 @@ describe("Review fixes: storeImageMeta filename backfill", () => {
2050
2353
  const img = new Image(50, 60);
2051
2354
  const encoded = Buffer.from(await img.encode(1));
2052
2355
  const hash = "test-backfill-filename";
2053
- storeImageMeta(hash, encoded); // first call, no filename
2054
- storeImageMeta(hash, encoded, "photo.png"); // second call, with filename
2055
- const meta = _imageMeta.get(hash);
2356
+ const store = createImageMetaStore();
2357
+ storeImageMeta(store, hash, encoded); // first call, no filename
2358
+ storeImageMeta(store, hash, encoded, "photo.png"); // second call, with filename
2359
+ const meta = store.get(hash);
2056
2360
  assert.ok(meta, "meta should exist");
2057
2361
  assert.equal(meta!.filename, "photo.png", "filename should be backfilled");
2058
2362
  });
2363
+
2364
+ it("keeps stores isolated — one session's metadata does not leak into another (issue #12)", async () => {
2365
+ const { Image } = await import("imagescript");
2366
+ const encoded = Buffer.from(await new Image(40, 30).encode(1));
2367
+ const sessionA = createImageMetaStore();
2368
+ const sessionB = createImageMetaStore();
2369
+ storeImageMeta(sessionA, "shared-hash", encoded, "a.png");
2370
+ assert.ok(sessionA.get("shared-hash"), "session A should have the metadata");
2371
+ assert.equal(sessionB.get("shared-hash"), undefined, "session B must not inherit it");
2372
+ });
2059
2373
  });