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.
- package/CHANGELOG.md +15 -0
- package/README.md +242 -220
- package/extensions/__tests__/integration.test.ts +0 -2
- package/extensions/__tests__/internal.test.ts +326 -12
- package/extensions/internal.ts +465 -28
- package/extensions/vision-proxy.ts +2493 -2326
- package/package.json +2 -2
- package/.pi/ghost-autocomplete/metrics.jsonl +0 -192
- package/.pi/ghost-autocomplete/profile.jsonl +0 -19
- package/bash.exe.stackdump +0 -28
|
@@ -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
|
-
|
|
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("
|
|
595
|
-
|
|
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
|
-
|
|
1962
|
-
|
|
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
|
-
|
|
2054
|
-
storeImageMeta(hash, encoded
|
|
2055
|
-
|
|
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
|
});
|